mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
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
553 lines
19 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
// 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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
// 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 };
|
|
},
|
|
});
|
|
|