Files
wiki/convex/newsletterActions.ts
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

553 lines
19 KiB
TypeScript

"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, "&")
.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 };
},
});