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
This commit is contained in:
Wayne Sutton
2025-12-27 15:32:07 -08:00
parent c312a4c808
commit a87db9d171
55 changed files with 7753 additions and 1260 deletions

View File

@@ -10,8 +10,12 @@
import type * as aiChatActions from "../aiChatActions.js";
import type * as aiChats from "../aiChats.js";
import type * as contact from "../contact.js";
import type * as contactActions from "../contactActions.js";
import type * as crons from "../crons.js";
import type * as http from "../http.js";
import type * as newsletter from "../newsletter.js";
import type * as newsletterActions from "../newsletterActions.js";
import type * as pages from "../pages.js";
import type * as posts from "../posts.js";
import type * as rss from "../rss.js";
@@ -27,8 +31,12 @@ import type {
declare const fullApi: ApiFromModules<{
aiChatActions: typeof aiChatActions;
aiChats: typeof aiChats;
contact: typeof contact;
contactActions: typeof contactActions;
crons: typeof crons;
http: typeof http;
newsletter: typeof newsletter;
newsletterActions: typeof newsletterActions;
pages: typeof pages;
posts: typeof posts;
rss: typeof rss;

84
convex/contact.ts Normal file
View File

@@ -0,0 +1,84 @@
import { mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Environment variable error message for production
const ENV_VAR_ERROR_MESSAGE = "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY, AGENTMAIL_INBOX, and AGENTMAIL_CONTACT_EMAIL.";
// Submit contact form message
// Stores the message and schedules email sending via AgentMail
export const submitContact = mutation({
args: {
name: v.string(),
email: v.string(),
message: v.string(),
source: v.string(), // "page:slug" or "post:slug"
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Validate required fields
const name = args.name.trim();
const email = args.email.toLowerCase().trim();
const message = args.message.trim();
if (!name) {
return { success: false, message: "Please enter your name." };
}
if (!email || !email.includes("@") || !email.includes(".")) {
return { success: false, message: "Please enter a valid email address." };
}
if (!message) {
return { success: false, message: "Please enter a message." };
}
// Check environment variables before proceeding
// Note: We can't access process.env in mutations, so we check in the action
// But we can still store the message and let the action handle the error
// For now, we'll store the message and let the action fail silently
// The user will see a success message but email won't send if env vars are missing
// Store the message
const messageId = await ctx.db.insert("contactMessages", {
name,
email,
message,
source: args.source,
createdAt: Date.now(),
});
// Schedule email sending via Node.js action
// The action will check env vars and fail silently if not configured
await ctx.scheduler.runAfter(0, internal.contactActions.sendContactEmail, {
messageId,
name,
email,
message,
source: args.source,
});
return {
success: true,
message: "Thanks for your message! We'll get back to you soon.",
};
},
});
// Mark contact message as email sent
// Internal mutation to update emailSentAt timestamp
export const markEmailSent = internalMutation({
args: {
messageId: v.id("contactMessages"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.messageId, {
emailSentAt: Date.now(),
});
return null;
},
});

90
convex/contactActions.ts Normal file
View File

@@ -0,0 +1,90 @@
"use node";
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import { AgentMailClient } from "agentmail";
// Send contact form email via AgentMail SDK
// Internal action that sends email to configured recipient
// Uses official AgentMail SDK: https://docs.agentmail.to/quickstart
export const sendContactEmail = internalAction({
args: {
messageId: v.id("contactMessages"),
name: v.string(),
email: v.string(),
message: v.string(),
source: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
// Contact form sends to AGENTMAIL_CONTACT_EMAIL or falls back to inbox
const recipientEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
// Silently fail if environment variables not configured
if (!apiKey || !inbox || !recipientEmail) {
return null;
}
// Build email HTML
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">New Contact Form Submission</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600; width: 100px;">From:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(args.name)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600;">Email:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><a href="mailto:${escapeHtml(args.email)}">${escapeHtml(args.email)}</a></td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600;">Source:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(args.source)}</td>
</tr>
</table>
<h3 style="font-size: 16px; color: #1a1a1a; margin: 24px 0 8px 0;">Message:</h3>
<div style="background: #f9f9f9; padding: 16px; border-radius: 6px; white-space: pre-wrap;">${escapeHtml(args.message)}</div>
</div>
`;
// Plain text version
const text = `New Contact Form Submission\n\nFrom: ${args.name}\nEmail: ${args.email}\nSource: ${args.source}\n\nMessage:\n${args.message}`;
try {
// Initialize AgentMail client with API key
const client = new AgentMailClient({ apiKey });
// Send email using official SDK
// https://docs.agentmail.to/sending-receiving-email
await client.inboxes.messages.send(inbox, {
to: recipientEmail,
subject: `Contact: ${args.name} via ${args.source}`,
text,
html,
});
// Mark email as sent in database
await ctx.runMutation(internal.contact.markEmailSent, {
messageId: args.messageId,
});
} catch {
// Silently fail on error
}
return null;
},
});
// Helper function to escape HTML entities
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -11,5 +11,29 @@ crons.interval(
{}
);
// Weekly digest: Send every Sunday at 9:00 AM UTC
// Posts from the last 7 days are included
// To disable, set weeklyDigest.enabled: false in siteConfig.ts
crons.cron(
"weekly newsletter digest",
"0 9 * * 0", // 9:00 AM UTC on Sundays
internal.newsletterActions.sendWeeklyDigest,
{
siteUrl: process.env.SITE_URL || "https://example.com",
siteName: process.env.SITE_NAME || "Newsletter",
}
);
// Weekly stats summary: Send every Monday at 9:00 AM UTC
// Includes subscriber count, new subscribers, newsletters sent
crons.cron(
"weekly stats summary",
"0 9 * * 1", // 9:00 AM UTC on Mondays
internal.newsletterActions.sendWeeklyStatsSummary,
{
siteName: process.env.SITE_NAME || "Newsletter",
}
);
export default crons;

View File

@@ -41,7 +41,7 @@ http.route({
</url>`,
// All posts
...posts.map(
(post) => ` <url>
(post: { slug: string; date: string }) => ` <url>
<loc>${SITE_URL}/${post.slug}</loc>
<lastmod>${post.date}</lastmod>
<changefreq>monthly</changefreq>
@@ -50,7 +50,7 @@ http.route({
),
// All pages
...pages.map(
(page) => ` <url>
(page: { slug: string }) => ` <url>
<loc>${SITE_URL}/${page.slug}</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
@@ -58,7 +58,7 @@ http.route({
),
// All tag pages
...tags.map(
(tagInfo) => ` <url>
(tagInfo: { tag: string }) => ` <url>
<loc>${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())}</loc>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
@@ -92,7 +92,7 @@ http.route({
url: SITE_URL,
description:
"An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.",
posts: posts.map((post) => ({
posts: posts.map((post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => ({
title: post.title,
slug: post.slug,
description: post.description,
@@ -193,7 +193,7 @@ http.route({
// Fetch full content for each post
const fullPosts = await Promise.all(
posts.map(async (post) => {
posts.map(async (post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => {
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
slug: post.slug,
});

561
convex/newsletter.ts Normal file
View File

@@ -0,0 +1,561 @@
import {
mutation,
query,
internalQuery,
internalMutation,
} from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Generate secure unsubscribe token
// Uses random alphanumeric characters for URL-safe tokens
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)
// Creates new subscriber or re-subscribes existing unsubscribed user
// Sends developer notification when a new subscriber signs up
export const subscribe = mutation({
args: {
email: v.string(),
source: v.string(), // "home", "blog-page", "post", or "post:slug-name"
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Normalize email: lowercase and trim whitespace
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 using index
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();
const isNewSubscriber = !existing;
if (existing) {
// Re-subscribe existing user with new token
await ctx.db.patch(existing._id, {
subscribed: true,
subscribedAt: Date.now(),
source: args.source,
unsubscribeToken: token,
unsubscribedAt: undefined,
});
} else {
// Create new subscriber
await ctx.db.insert("newsletterSubscribers", {
email,
subscribed: true,
subscribedAt: Date.now(),
source: args.source,
unsubscribeToken: token,
});
}
// Send developer notification for new subscribers
// Only for genuinely new subscribers, not re-subscriptions
if (isNewSubscriber) {
await ctx.scheduler.runAfter(0, internal.newsletterActions.notifyNewSubscriber, {
email,
source: args.source,
});
}
return { success: true, message: "Thanks for subscribing!" };
},
});
// Unsubscribe from newsletter
// Requires email and token for security (prevents unauthorized unsubscribes)
export const unsubscribe = mutation({
args: {
email: v.string(),
token: v.string(),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Normalize email
const email = args.email.toLowerCase().trim();
// Find subscriber by email using index
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." };
}
// Verify token matches
if (subscriber.unsubscribeToken !== args.token) {
return { success: false, message: "Invalid unsubscribe link." };
}
// Check if already unsubscribed
if (!subscriber.subscribed) {
return { success: true, message: "You're already unsubscribed." };
}
// Mark as unsubscribed
await ctx.db.patch(subscriber._id, {
subscribed: false,
unsubscribedAt: Date.now(),
});
return { success: true, message: "You've been unsubscribed." };
},
});
// Get subscriber count (for stats page)
// Returns count of active subscribers
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 newsletters)
// Returns only email and token for each active subscriber
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,
}));
},
});
// Record that a post was sent as newsletter
// Internal mutation called after sending newsletter
export const recordPostSent = internalMutation({
args: {
postSlug: v.string(),
sentCount: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert("newsletterSentPosts", {
postSlug: args.postSlug,
sentAt: Date.now(),
sentCount: args.sentCount,
type: "post",
});
return null;
},
});
// Record that a custom email was sent as newsletter
// Internal mutation called after sending custom newsletter
export const recordCustomSent = internalMutation({
args: {
subject: v.string(),
sentCount: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Generate a unique identifier for the custom email
const customId = `custom-${Date.now()}`;
await ctx.db.insert("newsletterSentPosts", {
postSlug: customId,
sentAt: Date.now(),
sentCount: args.sentCount,
type: "custom",
subject: args.subject,
});
return null;
},
});
// Check if a post has already been sent as newsletter
export const wasPostSent = internalQuery({
args: {
postSlug: v.string(),
},
returns: v.boolean(),
handler: async (ctx, args) => {
const sent = await ctx.db
.query("newsletterSentPosts")
.withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug))
.first();
return sent !== null;
},
});
// ============================================================================
// Admin Queries and Mutations
// For use in the /newsletter-admin page
// ============================================================================
// Subscriber type for admin queries (excludes sensitive tokens for public queries)
const subscriberAdminValidator = v.object({
_id: v.id("newsletterSubscribers"),
email: v.string(),
subscribed: v.boolean(),
subscribedAt: v.number(),
unsubscribedAt: v.optional(v.number()),
source: v.string(),
});
// Get all subscribers for admin (paginated)
// Returns subscribers without sensitive unsubscribe tokens
export const getAllSubscribers = query({
args: {
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
filter: v.optional(v.union(v.literal("all"), v.literal("subscribed"), v.literal("unsubscribed"))),
search: v.optional(v.string()),
},
returns: v.object({
subscribers: v.array(subscriberAdminValidator),
nextCursor: v.union(v.string(), v.null()),
totalCount: v.number(),
subscribedCount: v.number(),
}),
handler: async (ctx, args) => {
const limit = args.limit ?? 50;
const filter = args.filter ?? "all";
const search = args.search?.toLowerCase().trim();
// Get all subscribers for counting
const allSubscribers = await ctx.db
.query("newsletterSubscribers")
.collect();
// Filter by subscription status
let filtered = allSubscribers;
if (filter === "subscribed") {
filtered = allSubscribers.filter((s) => s.subscribed);
} else if (filter === "unsubscribed") {
filtered = allSubscribers.filter((s) => !s.subscribed);
}
// Search by email
if (search) {
filtered = filtered.filter((s) => s.email.includes(search));
}
// Sort by subscribedAt descending (newest first)
filtered.sort((a, b) => b.subscribedAt - a.subscribedAt);
// Pagination using cursor (subscribedAt timestamp)
let startIndex = 0;
if (args.cursor) {
const cursorTime = parseInt(args.cursor, 10);
startIndex = filtered.findIndex((s) => s.subscribedAt < cursorTime);
if (startIndex === -1) startIndex = filtered.length;
}
const pageSubscribers = filtered.slice(startIndex, startIndex + limit);
const hasMore = startIndex + limit < filtered.length;
// Map to admin format (strip unsubscribeToken)
const subscribers = pageSubscribers.map((s) => ({
_id: s._id,
email: s.email,
subscribed: s.subscribed,
subscribedAt: s.subscribedAt,
unsubscribedAt: s.unsubscribedAt,
source: s.source,
}));
const subscribedCount = allSubscribers.filter((s) => s.subscribed).length;
return {
subscribers,
nextCursor: hasMore ? String(pageSubscribers[pageSubscribers.length - 1].subscribedAt) : null,
totalCount: filtered.length,
subscribedCount,
};
},
});
// Delete subscriber (admin only)
// Permanently removes subscriber from database
export const deleteSubscriber = mutation({
args: {
subscriberId: v.id("newsletterSubscribers"),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Check if subscriber exists using direct get
const subscriber = await ctx.db.get(args.subscriberId);
if (!subscriber) {
return { success: false, message: "Subscriber not found." };
}
// Delete the subscriber
await ctx.db.delete(args.subscriberId);
return { success: true, message: "Subscriber deleted." };
},
});
// Get newsletter stats for admin dashboard
export const getNewsletterStats = query({
args: {},
returns: v.object({
totalSubscribers: v.number(),
activeSubscribers: v.number(),
unsubscribedCount: v.number(),
totalNewslettersSent: v.number(),
totalEmailsSent: v.number(), // Sum of all sentCount
recentNewsletters: v.array(
v.object({
postSlug: v.string(),
sentAt: v.number(),
sentCount: v.number(),
type: v.optional(v.string()),
subject: v.optional(v.string()),
})
),
}),
handler: async (ctx) => {
// Get all subscribers
const subscribers = await ctx.db.query("newsletterSubscribers").collect();
const activeSubscribers = subscribers.filter((s) => s.subscribed).length;
const unsubscribedCount = subscribers.length - activeSubscribers;
// Get sent newsletters
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
// Calculate total emails sent (sum of all sentCount)
const totalEmailsSent = sentPosts.reduce((sum, p) => sum + p.sentCount, 0);
// Sort by sentAt descending and take last 10
const recentNewsletters = sentPosts
.sort((a, b) => b.sentAt - a.sentAt)
.slice(0, 10)
.map((p) => ({
postSlug: p.postSlug,
sentAt: p.sentAt,
sentCount: p.sentCount,
type: p.type,
subject: p.subject,
}));
return {
totalSubscribers: subscribers.length,
activeSubscribers,
unsubscribedCount,
totalNewslettersSent: sentPosts.length,
totalEmailsSent,
recentNewsletters,
};
},
});
// Get list of posts available for newsletter sending
export const getPostsForNewsletter = query({
args: {},
returns: v.array(
v.object({
slug: v.string(),
title: v.string(),
date: v.string(),
wasSent: v.boolean(),
})
),
handler: async (ctx) => {
// Get all published posts
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Get all sent post slugs
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
const sentSlugs = new Set(sentPosts.map((p) => p.postSlug));
// Map posts with sent status, sorted by date descending
return posts
.sort((a, b) => b.date.localeCompare(a.date))
.map((p) => ({
slug: p.slug,
title: p.title,
date: p.date,
wasSent: sentSlugs.has(p.slug),
}));
},
});
// Internal query to get stats for weekly summary email
export const getStatsForSummary = internalQuery({
args: {},
returns: v.object({
activeSubscribers: v.number(),
totalSubscribers: v.number(),
newThisWeek: v.number(),
unsubscribedCount: v.number(),
totalNewslettersSent: v.number(),
}),
handler: async (ctx) => {
// Get all subscribers
const subscribers = await ctx.db.query("newsletterSubscribers").collect();
const activeSubscribers = subscribers.filter((s) => s.subscribed).length;
const unsubscribedCount = subscribers.length - activeSubscribers;
// Calculate new subscribers this week
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const newThisWeek = subscribers.filter(
(s) => s.subscribedAt >= oneWeekAgo && s.subscribed
).length;
// Get sent newsletters count
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
return {
activeSubscribers,
totalSubscribers: subscribers.length,
newThisWeek,
unsubscribedCount,
totalNewslettersSent: sentPosts.length,
};
},
});
// ============================================================================
// Admin Mutations for Newsletter Sending
// These schedule internal actions to send newsletters
// ============================================================================
// Schedule sending a post as newsletter from admin UI
export const scheduleSendPostNewsletter = mutation({
args: {
postSlug: v.string(),
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Check if post was already sent
const sent = await ctx.db
.query("newsletterSentPosts")
.withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug))
.first();
if (sent) {
return {
success: false,
message: "This post has already been sent as a newsletter.",
};
}
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendPostNewsletter, {
postSlug: args.postSlug,
siteUrl: args.siteUrl,
siteName: args.siteName,
});
return {
success: true,
message: "Newsletter is being sent. Check back in a moment for results.",
};
},
});
// Schedule sending a custom newsletter from admin UI
export const scheduleSendCustomNewsletter = mutation({
args: {
subject: v.string(),
content: v.string(),
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Validate inputs
if (!args.subject.trim()) {
return { success: false, message: "Subject is required." };
}
if (!args.content.trim()) {
return { success: false, message: "Content is required." };
}
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendCustomNewsletter, {
subject: args.subject,
content: args.content,
siteUrl: args.siteUrl,
siteName: args.siteName,
});
return {
success: true,
message: "Newsletter is being sent. Check back in a moment for results.",
};
},
});
// Schedule sending weekly stats summary from CLI
export const scheduleSendStatsSummary = mutation({
args: {
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendWeeklyStatsSummary, {
siteName: args.siteName,
});
return {
success: true,
message: "Stats summary is being sent. Check your inbox in a moment.",
};
},
});

552
convex/newsletterActions.ts Normal file
View File

@@ -0,0 +1,552 @@
"use node";
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import { AgentMailClient } from "agentmail";
// Simple markdown to HTML converter for email content
// Supports: headers, bold, italic, links, lists, paragraphs
function markdownToHtml(markdown: string): string {
let html = markdown
// Escape HTML entities first
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// Headers (must be at start of line)
.replace(/^### (.+)$/gm, '<h3 style="font-size: 18px; color: #1a1a1a; margin: 16px 0 8px;">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 style="font-size: 20px; color: #1a1a1a; margin: 20px 0 10px;">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 style="font-size: 24px; color: #1a1a1a; margin: 24px 0 12px;">$1</h1>')
// Bold and italic
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color: #1a73e8; text-decoration: none;">$1</a>')
// Unordered lists
.replace(/^- (.+)$/gm, '<li style="margin: 4px 0;">$1</li>')
.replace(/(<li[^>]*>.*<\/li>\n?)+/g, '<ul style="padding-left: 20px; margin: 12px 0;">$&</ul>')
// Line breaks (double newline = paragraph)
.replace(/\n\n/g, '</p><p style="margin: 12px 0; line-height: 1.6;">')
// Single line breaks
.replace(/\n/g, '<br />');
// Wrap in paragraph if not starting with a block element
if (!html.startsWith('<h') && !html.startsWith('<ul')) {
html = `<p style="margin: 12px 0; line-height: 1.6;">${html}</p>`;
}
return html;
}
// Convert markdown to plain text for email fallback
function markdownToText(markdown: string): string {
return markdown
// Remove markdown formatting
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/__(.+?)__/g, '$1')
.replace(/_(.+?)_/g, '$1')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
.replace(/^#{1,3} /gm, '')
.replace(/^- /gm, '* ');
}
// Environment variable error message for production
const ENV_VAR_ERROR_MESSAGE = "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX.";
// Send newsletter for a specific post to all active subscribers
// Uses AgentMail SDK to send emails
// https://docs.agentmail.to/sending-receiving-email
export const sendPostNewsletter = internalAction({
args: {
postSlug: v.string(),
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
sentCount: v.number(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Check if post was already sent
const alreadySent: boolean = await ctx.runQuery(
internal.newsletter.wasPostSent,
{ postSlug: args.postSlug }
);
if (alreadySent) {
return {
success: false,
sentCount: 0,
message: "This post has already been sent as a newsletter.",
};
}
// Get subscribers
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
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." };
}
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
if (!apiKey || !inbox) {
return {
success: false,
sentCount: 0,
message: ENV_VAR_ERROR_MESSAGE,
};
}
const siteName = args.siteName || "Newsletter";
let sentCount = 0;
const errors: Array<string> = [];
// Initialize AgentMail client once
const client = new AgentMailClient({ apiKey });
// Send to each subscriber
for (const subscriber of subscribers) {
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
const postUrl = `${args.siteUrl}/${post.slug}`;
// Build email HTML
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="font-size: 24px; color: #1a1a1a; margin-bottom: 16px;">${escapeHtml(post.title)}</h1>
<p style="font-size: 16px; color: #444; line-height: 1.6; margin-bottom: 24px;">${escapeHtml(post.description)}</p>
${post.excerpt ? `<p style="font-size: 14px; color: #666; line-height: 1.5; margin-bottom: 24px;">${escapeHtml(post.excerpt)}</p>` : ""}
<p style="margin-bottom: 32px;">
<a href="${postUrl}" style="display: inline-block; padding: 12px 24px; background: #1a1a1a; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Read more</a>
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="font-size: 12px; color: #888;">
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
</p>
</div>
`;
// Plain text version
const text = `${post.title}\n\n${post.description}\n\nRead more: ${postUrl}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
// Send email using AgentMail SDK
try {
await client.inboxes.messages.send(inbox, {
to: subscriber.email,
subject: `New: ${post.title}`,
html,
text,
});
sentCount++;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
errors.push(`${subscriber.email}: ${errorMessage}`);
}
}
// Record sent if at least one email was sent
if (sentCount > 0) {
await ctx.runMutation(internal.newsletter.recordPostSent, {
postSlug: args.postSlug,
sentCount,
});
}
// Build result message
let resultMessage: string = `Sent to ${sentCount} of ${subscribers.length} subscribers.`;
if (errors.length > 0) {
resultMessage += ` ${errors.length} failed.`;
}
return { success: sentCount > 0, sentCount, message: resultMessage };
},
});
// Helper function to escape HTML entities
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Send weekly digest email to all active subscribers
// Includes all posts published in the last 7 days
export const sendWeeklyDigest = internalAction({
args: {
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
sentCount: v.number(),
postCount: v.number(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Get subscribers
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
if (subscribers.length === 0) {
return {
success: false,
sentCount: 0,
postCount: 0,
message: "No subscribers.",
};
}
// Get posts from last 7 days
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const cutoffDate = sevenDaysAgo.toISOString().split("T")[0];
const recentPosts: Array<{
slug: string;
title: string;
description: string;
date: string;
excerpt?: string;
}> = await ctx.runQuery(internal.posts.getRecentPostsInternal, {
since: cutoffDate,
});
if (recentPosts.length === 0) {
return {
success: true,
sentCount: 0,
postCount: 0,
message: "No new posts in the last 7 days.",
};
}
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
if (!apiKey || !inbox) {
return {
success: false,
sentCount: 0,
postCount: 0,
message: ENV_VAR_ERROR_MESSAGE,
};
}
const siteName = args.siteName || "Newsletter";
let sentCount = 0;
const errors: Array<string> = [];
// Initialize AgentMail client
const client = new AgentMailClient({ apiKey });
// Build email content
const postsHtml = recentPosts
.map(
(post) => `
<div style="margin-bottom: 24px; padding: 16px; background: #f9f9f9; border-radius: 8px;">
<h3 style="font-size: 18px; color: #1a1a1a; margin: 0 0 8px 0;">
<a href="${args.siteUrl}/${post.slug}" style="color: #1a1a1a; text-decoration: none;">${escapeHtml(post.title)}</a>
</h3>
<p style="font-size: 14px; color: #666; margin: 0 0 8px 0;">${escapeHtml(post.description)}</p>
<p style="font-size: 12px; color: #888; margin: 0;">${post.date}</p>
</div>
`
)
.join("");
const postsText = recentPosts
.map(
(post) =>
`${post.title}\n${post.description}\n${args.siteUrl}/${post.slug}\n${post.date}`
)
.join("\n\n");
// Send to each subscriber
for (const subscriber of subscribers) {
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="font-size: 24px; color: #1a1a1a; margin-bottom: 8px;">Weekly Digest</h1>
<p style="font-size: 14px; color: #666; margin-bottom: 24px;">${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""} from ${escapeHtml(siteName)}</p>
${postsHtml}
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="font-size: 12px; color: #888;">
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
</p>
</div>
`;
const text = `Weekly Digest - ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""}\n\n${postsText}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
try {
await client.inboxes.messages.send(inbox, {
to: subscriber.email,
subject: `Weekly Digest: ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""}`,
html,
text,
});
sentCount++;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
errors.push(`${subscriber.email}: ${errorMessage}`);
}
}
let resultMessage: string = `Sent ${recentPosts.length} post${recentPosts.length > 1 ? "s" : ""} to ${sentCount} of ${subscribers.length} subscribers.`;
if (errors.length > 0) {
resultMessage += ` ${errors.length} failed.`;
}
return {
success: sentCount > 0,
sentCount,
postCount: recentPosts.length,
message: resultMessage,
};
},
});
// Send new subscriber notification to developer
// Called when a new subscriber signs up
export const notifyNewSubscriber = internalAction({
args: {
email: v.string(),
source: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (_ctx, args) => {
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
const contactEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
if (!apiKey || !contactEmail) {
return {
success: false,
message: ENV_VAR_ERROR_MESSAGE,
};
}
const siteName = args.siteName || "Your Site";
const timestamp = new Date().toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
});
const client = new AgentMailClient({ apiKey });
try {
await client.inboxes.messages.send(inbox!, {
to: contactEmail,
subject: `New subscriber: ${args.email}`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">New Newsletter Subscriber</h2>
<p style="font-size: 14px; color: #444; line-height: 1.6;">
<strong>Email:</strong> ${escapeHtml(args.email)}<br />
<strong>Source:</strong> ${escapeHtml(args.source)}<br />
<strong>Time:</strong> ${timestamp}
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="font-size: 12px; color: #888;">
This is an automated notification from ${escapeHtml(siteName)}.
</p>
</div>
`,
text: `New Newsletter Subscriber\n\nEmail: ${args.email}\nSource: ${args.source}\nTime: ${timestamp}`,
});
return { success: true, message: "Notification sent." };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return { success: false, message: errorMessage };
}
},
});
// Send weekly stats summary to developer
// Includes subscriber count, new subscribers, newsletters sent
export const sendWeeklyStatsSummary = internalAction({
args: {
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
const contactEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
if (!apiKey || !contactEmail) {
return { success: false, message: ENV_VAR_ERROR_MESSAGE };
}
const siteName = args.siteName || "Your Site";
// Get stats from database
const stats = await ctx.runQuery(internal.newsletter.getStatsForSummary);
const client = new AgentMailClient({ apiKey });
try {
await client.inboxes.messages.send(inbox!, {
to: contactEmail,
subject: `Weekly Stats: ${stats.activeSubscribers} subscribers`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">Weekly Newsletter Stats</h2>
<div style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin-bottom: 24px;">
<p style="font-size: 14px; color: #444; line-height: 1.8; margin: 0;">
<strong>Active Subscribers:</strong> ${stats.activeSubscribers}<br />
<strong>Total Subscribers:</strong> ${stats.totalSubscribers}<br />
<strong>New This Week:</strong> ${stats.newThisWeek}<br />
<strong>Unsubscribed:</strong> ${stats.unsubscribedCount}<br />
<strong>Newsletters Sent:</strong> ${stats.totalNewslettersSent}
</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="font-size: 12px; color: #888;">
This is an automated weekly summary from ${escapeHtml(siteName)}.
</p>
</div>
`,
text: `Weekly Newsletter Stats\n\nActive Subscribers: ${stats.activeSubscribers}\nTotal Subscribers: ${stats.totalSubscribers}\nNew This Week: ${stats.newThisWeek}\nUnsubscribed: ${stats.unsubscribedCount}\nNewsletters Sent: ${stats.totalNewslettersSent}`,
});
return { success: true, message: "Stats summary sent." };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return { success: false, message: errorMessage };
}
},
});
// Send custom newsletter email to all active subscribers
// Supports markdown content that gets converted to HTML
export const sendCustomNewsletter = internalAction({
args: {
subject: v.string(),
content: v.string(), // Markdown content
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
sentCount: v.number(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Get subscribers
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
if (subscribers.length === 0) {
return { success: false, sentCount: 0, message: "No subscribers." };
}
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
if (!apiKey || !inbox) {
return {
success: false,
sentCount: 0,
message: ENV_VAR_ERROR_MESSAGE,
};
}
const siteName = args.siteName || "Newsletter";
let sentCount = 0;
const errors: Array<string> = [];
// Convert markdown to HTML and plain text
const contentHtml = markdownToHtml(args.content);
const contentText = markdownToText(args.content);
// Initialize AgentMail client
const client = new AgentMailClient({ apiKey });
// Send to each subscriber
for (const subscriber of subscribers) {
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
// Build email HTML with styling
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
${contentHtml}
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="font-size: 12px; color: #888;">
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
</p>
</div>
`;
const text = `${contentText}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
try {
await client.inboxes.messages.send(inbox, {
to: subscriber.email,
subject: args.subject,
html,
text,
});
sentCount++;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
errors.push(`${subscriber.email}: ${errorMessage}`);
}
}
// Record custom email send if at least one email was sent
if (sentCount > 0) {
await ctx.runMutation(internal.newsletter.recordCustomSent, {
subject: args.subject,
sentCount,
});
}
let resultMessage: string = `Sent to ${sentCount} of ${subscribers.length} subscribers.`;
if (errors.length > 0) {
resultMessage += ` ${errors.length} failed.`;
}
return { success: sentCount > 0, sentCount, message: resultMessage };
},
});

View File

@@ -128,7 +128,10 @@ export const getPageBySlug = query({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
}),
v.null(),
),
@@ -161,7 +164,10 @@ export const getPageBySlug = query({
rightSidebar: page.rightSidebar,
showFooter: page.showFooter,
footer: page.footer,
showSocialFooter: page.showSocialFooter,
aiChat: page.aiChat,
contactForm: page.contactForm,
newsletter: page.newsletter,
};
},
});
@@ -188,7 +194,10 @@ export const syncPagesPublic = mutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
}),
),
},
@@ -232,7 +241,10 @@ export const syncPagesPublic = mutation({
rightSidebar: page.rightSidebar,
showFooter: page.showFooter,
footer: page.footer,
showSocialFooter: page.showSocialFooter,
aiChat: page.aiChat,
contactForm: page.contactForm,
newsletter: page.newsletter,
lastSyncedAt: now,
});
updated++;

View File

@@ -1,4 +1,4 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
// Get all published posts, sorted by date descending
@@ -179,7 +179,10 @@ export const getPostBySlug = query({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
}),
v.null(),
),
@@ -215,11 +218,87 @@ export const getPostBySlug = query({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
aiChat: post.aiChat,
newsletter: post.newsletter,
contactForm: post.contactForm,
};
},
});
// Internal query to get post by slug (for newsletter sending)
// Returns post details needed for newsletter content
export const getPostBySlugInternal = internalQuery({
args: {
slug: v.string(),
},
returns: v.union(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
excerpt: v.optional(v.string()),
}),
v.null(),
),
handler: async (ctx, args) => {
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!post || !post.published) {
return null;
}
return {
slug: post.slug,
title: post.title,
description: post.description,
content: post.content,
excerpt: post.excerpt,
};
},
});
// Internal query to get recent posts (for weekly digest)
// Returns published posts with date >= since parameter
export const getRecentPostsInternal = internalQuery({
args: {
since: v.string(), // Date string in YYYY-MM-DD format
},
returns: v.array(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
date: v.string(),
excerpt: v.optional(v.string()),
})
),
handler: async (ctx, args) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Filter posts by date and sort descending
const recentPosts = posts
.filter((post) => post.date >= args.since)
.sort((a, b) => b.date.localeCompare(a.date))
.map((post) => ({
slug: post.slug,
title: post.title,
description: post.description,
date: post.date,
excerpt: post.excerpt,
}));
return recentPosts;
},
});
// Internal mutation for syncing posts from markdown files
export const syncPosts = internalMutation({
args: {
@@ -244,8 +323,11 @@ export const syncPosts = internalMutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
}),
),
},
@@ -291,8 +373,11 @@ export const syncPosts = internalMutation({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
aiChat: post.aiChat,
blogFeatured: post.blogFeatured,
newsletter: post.newsletter,
contactForm: post.contactForm,
lastSyncedAt: now,
});
updated++;
@@ -342,8 +427,11 @@ export const syncPostsPublic = mutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
}),
),
},
@@ -389,8 +477,11 @@ export const syncPostsPublic = mutation({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
aiChat: post.aiChat,
blogFeatured: post.blogFeatured,
newsletter: post.newsletter,
contactForm: post.contactForm,
lastSyncedAt: now,
});
updated++;

View File

@@ -106,7 +106,7 @@ export const rssFeed = httpAction(async (ctx) => {
const posts = await ctx.runQuery(api.posts.getAllPosts);
const xml = generateRssXml(
posts.map((post) => ({
posts.map((post: { title: string; description: string; slug: string; date: string }) => ({
title: post.title,
description: post.description,
slug: post.slug,
@@ -128,7 +128,7 @@ export const rssFullFeed = httpAction(async (ctx) => {
// Fetch full content for each post
const fullPosts = await Promise.all(
posts.map(async (post) => {
posts.map(async (post: { title: string; description: string; slug: string; date: string; readTime?: string; tags: string[] }) => {
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
slug: post.slug,
});

View File

@@ -23,8 +23,11 @@ export default defineSchema({
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
showFooter: v.optional(v.boolean()), // Show footer on this post (overrides siteConfig default)
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
showSocialFooter: v.optional(v.boolean()), // Show social footer on this post (overrides siteConfig default)
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
blogFeatured: v.optional(v.boolean()), // Show as hero featured post on /blog page
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
contactForm: v.optional(v.boolean()), // Enable contact form on this post
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
@@ -60,7 +63,10 @@ export default defineSchema({
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
showFooter: v.optional(v.boolean()), // Show footer on this page (overrides siteConfig default)
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
showSocialFooter: v.optional(v.boolean()), // Show social footer on this page (overrides siteConfig default)
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
contactForm: v.optional(v.boolean()), // Enable contact form on this page
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
@@ -139,4 +145,40 @@ export default defineSchema({
})
.index("by_session_and_context", ["sessionId", "contextId"])
.index("by_session", ["sessionId"]),
// Newsletter subscribers table
// Stores email subscriptions with unsubscribe tokens
newsletterSubscribers: defineTable({
email: v.string(), // Subscriber email address (lowercase, trimmed)
subscribed: v.boolean(), // Current subscription status
subscribedAt: v.number(), // Timestamp when subscribed
unsubscribedAt: v.optional(v.number()), // Timestamp when unsubscribed (if applicable)
source: v.string(), // Where they signed up: "home", "blog-page", "post", or "post:slug-name"
unsubscribeToken: v.string(), // Secure token for unsubscribe links
})
.index("by_email", ["email"])
.index("by_subscribed", ["subscribed"]),
// Newsletter sent tracking (posts and custom emails)
// Tracks what has been sent to prevent duplicate newsletters
newsletterSentPosts: defineTable({
postSlug: v.string(), // Slug of the post or custom email identifier
sentAt: v.number(), // Timestamp when the newsletter was sent
sentCount: v.number(), // Number of subscribers it was sent to
type: v.optional(v.string()), // "post" or "custom" (default "post" for backwards compat)
subject: v.optional(v.string()), // Subject line for custom emails
})
.index("by_postSlug", ["postSlug"])
.index("by_sentAt", ["sentAt"]),
// Contact form messages
// Stores messages submitted via contact forms on posts/pages
contactMessages: defineTable({
name: v.string(), // Sender's name
email: v.string(), // Sender's email address
message: v.string(), // Message content
source: v.string(), // Where submitted from: "page:slug" or "post:slug"
createdAt: v.number(), // Timestamp when submitted
emailSentAt: v.optional(v.number()), // Timestamp when email was sent (if applicable)
}).index("by_createdAt", ["createdAt"]),
});