diff --git a/.cursor/plans/agentmail_newsletter_integration_03119c86.plan.md b/.cursor/plans/agentmail_newsletter_integration_03119c86.plan.md index 8f35539..55a61ba 100644 --- a/.cursor/plans/agentmail_newsletter_integration_03119c86.plan.md +++ b/.cursor/plans/agentmail_newsletter_integration_03119c86.plan.md @@ -1,447 +1,291 @@ --- name: AgentMail Newsletter Integration -overview: Integrate AgentMail for newsletter subscriptions, email-to-post workflow, automated post notifications, and developer notifications. All features are optional and configurable via siteConfig.ts. Includes separate newsletter page, unsubscribe functionality, and auto-send for new blog posts. +overview: | + 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. todos: - - id: schema-updates - content: Update convex/schema.ts with newsletterSubscribers, newsletterSentPosts, and emailDrafts tables - status: pending - - id: site-config - content: Add NewsletterConfig interface and default configuration to src/config/siteConfig.ts - status: pending - - id: newsletter-component - content: Create src/components/NewsletterSignup.tsx component with name/email form options - status: pending + - id: p1a-site-config + content: Add NewsletterConfig interface and defaults to siteConfig.ts (email-only signup) + status: completed + - id: p1a-schema-subscribers + content: Add newsletterSubscribers table to convex/schema.ts + status: completed + - id: p1b-subscribe-mutation + content: Create convex/newsletter.ts with subscribe mutation + status: completed dependencies: - - site-config - - id: convex-functions - content: Create convex/newsletter.ts with subscribe, unsubscribe, sendPostNewsletter functions - status: pending + - p1a-schema-subscribers + - id: p1b-unsubscribe-mutation + content: Add unsubscribe mutation with token verification to newsletter.ts + status: completed dependencies: - - schema-updates - - id: send-script - content: Create scripts/send-newsletter.ts for manual newsletter sending - status: pending + - p1a-schema-subscribers + - id: p1b-subscriber-queries + content: Add getSubscriberCount and getActiveSubscribers queries + status: completed dependencies: - - convex-functions - - id: auto-send-integration - content: Modify scripts/sync-posts.ts to auto-send new posts if enabled - status: pending + - p1a-schema-subscribers + - id: p1c-newsletter-component + content: Create NewsletterSignup.tsx component (email-only input) + status: completed dependencies: - - convex-functions - - send-script - - id: newsletter-page - content: Create content/pages/newsletter.md and integrate NewsletterSignup in Post.tsx - status: pending + - p1a-site-config + - id: p1c-css-styling + content: Add newsletter component styles to global.css (all themes) + status: completed dependencies: - - newsletter-component - - id: unsubscribe-page - content: Create src/pages/Unsubscribe.tsx and add route in App.tsx - status: pending + - p1c-newsletter-component + - id: p1d-home-integration + content: Add NewsletterSignup to Home.tsx (configurable position) + status: completed dependencies: - - convex-functions - - id: home-integration - content: Add NewsletterSignup component to Home.tsx above footer - status: pending + - p1c-newsletter-component + - p1b-subscribe-mutation + - id: p1d-blog-page-integration + content: Add NewsletterSignup to Blog.tsx page + status: completed dependencies: - - newsletter-component - - id: post-integration - content: Add NewsletterSignup component to Post.tsx below content (posts only) - status: pending + - p1c-newsletter-component + - p1b-subscribe-mutation + - id: p1d-post-integration + content: Add NewsletterSignup to Post.tsx with frontmatter support + status: completed dependencies: - - newsletter-component - - id: webhook-handler - content: Create convex/webhooks.ts and add route in convex/http.ts for future email-to-post - status: pending + - p1c-newsletter-component + - p1b-subscribe-mutation + - id: p1e-unsubscribe-page + content: Create Unsubscribe.tsx page component + status: completed dependencies: - - schema-updates - - id: css-styling - content: Add newsletter component styles to src/styles/global.css matching existing theme - status: pending + - p1b-unsubscribe-mutation + - id: p1e-unsubscribe-route + content: Add /unsubscribe route to App.tsx + status: completed dependencies: - - newsletter-component - - id: fork-config - content: Update fork-config.json.example and FORK_CONFIG.md with newsletter configuration - status: pending + - p1e-unsubscribe-page + - id: p2-schema-sent-posts + content: Add newsletterSentPosts table to track sent newsletters + status: completed dependencies: - - site-config - - id: package-scripts + - p1a-schema-subscribers + - id: p2-send-action + content: Create sendPostNewsletter internalAction using AgentMail API + status: completed + dependencies: + - p2-schema-sent-posts + - p1b-subscriber-queries + - id: p2-send-script + content: Create scripts/send-newsletter.ts CLI tool + status: completed + dependencies: + - p2-send-action + - id: p2-package-scripts content: Add newsletter:send script to package.json - status: pending + status: completed dependencies: - - send-script - - id: save-plan - content: Save this plan as prds/agentmailplan-v1.md - status: pending + - p2-send-script + - id: p3-fork-config + content: Update fork-config.json.example with newsletter settings + status: completed + dependencies: + - p1a-site-config + - id: p3-fork-docs + content: Update FORK_CONFIG.md with newsletter configuration docs + status: completed + dependencies: + - p3-fork-config + - id: p3-save-prd + content: Save final plan as prds/agentmail-newsletter-v1.md + status: completed --- -# AgentMail Newsletter Integration Plan +# AgentMail Newsletter Integration Plan (Phased) ## Overview -Integrate AgentMail as an optional newsletter system with email subscriptions, automated post notifications, email-to-post workflow, and developer notifications. All features are optional and controlled via `siteConfig.ts`. +Integrate AgentMail as an optional newsletter system with email-only subscriptions. All features are optional and controlled via `siteConfig.ts` and frontmatter. -## Architecture +**Current Status:** AGENTMAIL_API_KEY is configured in Convex environment variables. -```mermaid -flowchart TD - User[User] -->|Subscribe| NewsletterForm[NewsletterSignup Component] - NewsletterForm -->|Mutation| ConvexDB[(Convex Database)] +--- - NewPost[New Blog Post Published] -->|Trigger| SendScript[Send Newsletter Script] - SendScript -->|Query Subscribers| ConvexDB - SendScript -->|Send Emails| AgentMail[AgentMail API] - AgentMail -->|Deliver| Subscribers[Subscriber Inboxes] +## How AgentMail Works - EmailIn[Email to Inbox] -->|Webhook| AgentMail - AgentMail -->|Webhook| ConvexWebhook[Convex Webhook Handler] - ConvexWebhook -->|Create Draft| ConvexDB +### Sending Emails to Your Inbox - ContactForm[Contact Form] -->|Send Email| AgentMail - AgentMail -->|Notify| Developer[Developer Email] +AgentMail uses a REST API to send emails. Each inbox has a unique address: + +``` +{username}@{domain} ``` -## Implementation Steps +For example: `newsletter@mail.agentmail.to` or `newsletter@yourdomain.com` -### Step 1: Database Schema Updates - -**File:** `convex/schema.ts`Add two new tables: +### API Endpoint for Sending ```typescript -// Newsletter subscribers -newsletterSubscribers: defineTable({ - email: v.string(), - name: v.optional(v.string()), // Optional name field - subscribed: v.boolean(), - subscribedAt: v.number(), - unsubscribedAt: v.optional(v.number()), - source: v.string(), // "home", "post", "newsletter-page" - unsubscribeToken: v.string(), // For secure unsubscribe links - lastEmailSentAt: v.optional(v.number()), // Track last notification sent -}) - .index("by_email", ["email"]) - .index("by_subscribed", ["subscribed"]), - -// Track which posts have been sent to avoid duplicates -newsletterSentPosts: defineTable({ - postSlug: v.string(), - sentAt: v.number(), - sentCount: v.number(), // Number of subscribers notified -}) - .index("by_postSlug", ["postSlug"]) - .index("by_sentAt", ["sentAt"]), - -// Email-to-post drafts (for future email-to-post workflow) -emailDrafts: defineTable({ - from: v.string(), - subject: v.string(), - content: v.string(), // Markdown content - attachments: v.optional(v.array(v.string())), // Attachment URLs - status: v.string(), // "draft", "published", "rejected" - createdAt: v.number(), - publishedAt: v.optional(v.number()), -}) - .index("by_status", ["status"]) - .index("by_createdAt", ["createdAt"]), +// 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: "

New post published!

Read more...

", + text: "New post published! Read more...", // Plain text fallback + }), +}); ``` -### Step 2: Site Configuration +### Key Configuration -**File:** `src/config/siteConfig.ts`Add newsletter configuration interface and default config: +| 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` ```typescript -// Newsletter configuration +// Newsletter configuration (email-only signup) export interface NewsletterConfig { - enabled: boolean; // Master switch - if false, all newsletter features hidden - agentmail: { - apiKey?: string; // Stored in env, referenced here for type safety - inboxUsername: string; // e.g., "newsletter" -> newsletter@agentmail.to - inboxDomain: string; // e.g., "agentmail.to" or custom domain - contentInboxUsername?: string; // Separate inbox for email-to-post (optional) - }; - signup: { - // Form field configuration - requireName: boolean; // If true, show name field; if false, email only - showNameOptional: boolean; // If true, show name as optional field + enabled: boolean; // Master switch - // Home page signup + // 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"; // Only above-footer as requested + position: "above-footer" | "below-intro"; title: string; description: string; }; - // Blog post signup (not pages) + // 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; - position: "below-content"; // Below post content + enabled: boolean; // Default for all posts + position: "below-content"; title: string; description: string; }; - - // Dedicated newsletter page - page: { - enabled: boolean; - slug: string; // e.g., "newsletter" - title: string; - description: string; - }; - }; - notifications: { - // Auto-send new blog posts - autoSendNewPosts: boolean; // If true, automatically send when post published - sendOnSync: boolean; // Send during npm run sync if new post detected - - // Email template - fromName: string; - fromEmail: string; // Uses inboxUsername@inboxDomain - replyTo: string; // Optional reply-to address - includeExcerpt: boolean; // Include post excerpt in email - includeReadTime: boolean; // Include reading time - }; - digests: { - // Weekly/monthly digests (future feature) - weekly: { - enabled: boolean; - dayOfWeek: number; // 0 = Sunday, 6 = Saturday - }; - monthly: { - enabled: boolean; - dayOfMonth: number; // 1-28 - }; - }; - developer: { - // Developer notifications - enabled: boolean; - email: string; // Developer email for notifications - notifyOnNewSubscriber: boolean; - notifyOnStats: boolean; // Daily/weekly stats - statsFrequency: "daily" | "weekly"; // Stats email frequency - }; - contact: { - // Contact form via AgentMail - enabled: boolean; - inboxUsername: string; // Separate inbox for contact form }; } // Add to SiteConfig interface -export interface SiteConfig { - // ... existing fields - newsletter: NewsletterConfig; -} +newsletter: NewsletterConfig; -// Default configuration (all disabled by default) -export const siteConfig: SiteConfig = { - // ... existing config - newsletter: { - enabled: false, // Disabled by default - opt-in - agentmail: { - inboxUsername: "newsletter", - inboxDomain: "agentmail.to", - }, - signup: { - requireName: false, - showNameOptional: true, - home: { - enabled: false, - position: "above-footer", - title: "Stay Updated", - description: "Get new posts delivered to your inbox", - }, - posts: { - enabled: false, - position: "below-content", - title: "Enjoyed this post?", - description: "Subscribe for more updates", - }, - page: { - enabled: false, - slug: "newsletter", - title: "Newsletter", - description: "Subscribe to get updates about new posts and features", - }, - }, - notifications: { - autoSendNewPosts: false, - sendOnSync: false, - fromName: "Your Site Name", - fromEmail: "newsletter@agentmail.to", - replyTo: "", - includeExcerpt: true, - includeReadTime: true, - }, - digests: { - weekly: { enabled: false, dayOfWeek: 0 }, - monthly: { enabled: false, dayOfMonth: 1 }, - }, - developer: { +// Default configuration (disabled) +newsletter: { + enabled: false, + agentmail: { + inbox: "newsletter@mail.agentmail.to", + }, + signup: { + home: { enabled: false, - email: "", - notifyOnNewSubscriber: false, - notifyOnStats: false, - statsFrequency: "weekly", + position: "above-footer", + title: "Stay Updated", + description: "Get new posts delivered to your inbox.", }, - contact: { + blogPage: { enabled: false, - inboxUsername: "contact", + 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.", }, }, -}; +}, ``` -### Step 3: Newsletter Signup Component +#### 1A.2: Schema Updates -**File:** `src/components/NewsletterSignup.tsx`Create reusable signup form component matching existing UI patterns: +**File:** `convex/schema.ts` ```typescript -import { useState } from "react"; -import { useMutation } from "convex/react"; -import { api } from "../../convex/_generated/api"; -import siteConfig from "../config/siteConfig"; - -interface NewsletterSignupProps { - variant?: "compact" | "full"; // Compact for inline, full for dedicated page - source: "home" | "post" | "newsletter-page"; - title?: string; // Override default title - description?: string; // Override default description -} - -export default function NewsletterSignup({ - variant = "compact", - source, - title, - description, -}: NewsletterSignupProps) { - const [email, setEmail] = useState(""); - const [name, setName] = useState(""); - const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); - const [message, setMessage] = useState(""); - const subscribe = useMutation(api.newsletter.subscribe); - - // Get form configuration - const requireName = siteConfig.newsletter.signup.requireName; - const showNameOptional = siteConfig.newsletter.signup.showNameOptional; - const showNameField = requireName || showNameOptional; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!email || !email.includes("@")) { - setStatus("error"); - setMessage("Please enter a valid email address"); - return; - } - - if (requireName && !name.trim()) { - setStatus("error"); - setMessage("Name is required"); - return; - } - - setStatus("loading"); - try { - await subscribe({ - email, - name: name.trim() || undefined, - source, - }); - setStatus("success"); - setMessage("Thanks for subscribing! Check your email to confirm."); - setEmail(""); - setName(""); - } catch (error) { - setStatus("error"); - setMessage("Something went wrong. Please try again."); - } - }; - - if (!siteConfig.newsletter.enabled) return null; - - const config = siteConfig.newsletter.signup[source]; - if (!config.enabled) return null; - - const displayTitle = title || config.title; - const displayDescription = description || config.description; - - return ( -
-

{displayTitle}

- {displayDescription && ( -

{displayDescription}

- )} - {status === "success" ? ( -
{message}
- ) : ( -
- {showNameField && ( - setName(e.target.value)} - placeholder={requireName ? "Your name" : "Your name (optional)"} - className="newsletter-signup__input" - required={requireName} - disabled={status === "loading"} - /> - )} - setEmail(e.target.value)} - placeholder="your@email.com" - className="newsletter-signup__input" - required - disabled={status === "loading"} - /> - -
- )} - {status === "error" && ( -

{message}

- )} -
- ); -} +// 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"]), ``` -### Step 4: Convex Newsletter Functions +--- -**File:** `convex/newsletter.ts`Create backend functions following Convex best practices: +### Phase 1B: Backend Functions + +**Can start after schema is deployed** + +#### 1B.1: Subscribe Mutation + +**File:** `convex/newsletter.ts` ```typescript -import { - query, - mutation, - internalMutation, - internalAction, - action, -} from "./_generated/server"; +import { mutation, query, internalQuery } from "./_generated/server"; import { v } from "convex/values"; -import { internal } from "./_generated/api"; -import crypto from "crypto"; -// Generate unsubscribe token -function generateUnsubscribeToken(email: string): string { - const secret = - process.env.UNSUBSCRIBE_SECRET || "default-secret-change-in-production"; - return crypto - .createHash("sha256") - .update(email + secret) - .digest("hex") - .substring(0, 32); +// 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 +// Subscribe to newsletter (email only) export const subscribe = mutation({ args: { email: v.string(), - name: v.optional(v.string()), source: v.string(), }, returns: v.object({ @@ -451,6 +295,11 @@ export const subscribe = mutation({ 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") @@ -458,48 +307,40 @@ export const subscribe = mutation({ .first(); if (existing && existing.subscribed) { - return { - success: false, - message: "You're already subscribed!", - }; + return { success: false, message: "You're already subscribed!" }; } - const unsubscribeToken = generateUnsubscribeToken(email); + const token = generateToken(); - // Create or update subscriber if (existing) { // Re-subscribe await ctx.db.patch(existing._id, { subscribed: true, subscribedAt: Date.now(), source: args.source, - name: args.name, - unsubscribeToken, + unsubscribeToken: token, unsubscribedAt: undefined, }); } else { // New subscriber await ctx.db.insert("newsletterSubscribers", { email, - name: args.name, subscribed: true, subscribedAt: Date.now(), source: args.source, - unsubscribeToken, + unsubscribeToken: token, }); } - // Notify developer if enabled (via scheduled action) - // This will be handled in a separate scheduled function - - return { - success: true, - message: "Subscription successful!", - }; + return { success: true, message: "Thanks for subscribing!" }; }, }); +``` -// Unsubscribe (public endpoint) +#### 1B.2: Unsubscribe Mutation + +```typescript +// Unsubscribe from newsletter export const unsubscribe = mutation({ args: { email: v.string(), @@ -518,19 +359,15 @@ export const unsubscribe = mutation({ .first(); if (!subscriber) { - return { - success: false, - message: "Email not found in our list.", - }; + return { success: false, message: "Email not found." }; } - // Verify token - const expectedToken = generateUnsubscribeToken(email); if (subscriber.unsubscribeToken !== args.token) { - return { - success: false, - message: "Invalid unsubscribe link.", - }; + return { success: false, message: "Invalid unsubscribe link." }; + } + + if (!subscriber.subscribed) { + return { success: true, message: "You're already unsubscribed." }; } await ctx.db.patch(subscriber._id, { @@ -538,14 +375,15 @@ export const unsubscribe = mutation({ unsubscribedAt: Date.now(), }); - return { - success: true, - message: "You've been unsubscribed.", - }; + return { success: true, message: "You've been unsubscribed." }; }, }); +``` -// Get subscriber count (for admin/stats) +#### 1B.3: Subscriber Queries + +```typescript +// Get subscriber count (for stats page) export const getSubscriberCount = query({ args: {}, returns: v.number(), @@ -558,28 +396,13 @@ export const getSubscriberCount = query({ }, }); -// Check if post has been sent (internal query) -export const hasPostBeenSent = 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; - }, -}); - -// Get active subscribers (internal query) +// Get active subscribers (internal, for sending) export const getActiveSubscribers = internalQuery({ args: {}, returns: v.array( v.object({ email: v.string(), - name: v.optional(v.string()), + unsubscribeToken: v.string(), }), ), handler: async (ctx) => { @@ -588,540 +411,142 @@ export const getActiveSubscribers = internalQuery({ .withIndex("by_subscribed", (q) => q.eq("subscribed", true)) .collect(); - return subscribers.map((sub) => ({ - email: sub.email, - name: sub.name, + return subscribers.map((s) => ({ + email: s.email, + unsubscribeToken: s.unsubscribeToken, })); }, }); - -// Record that post was sent (internal mutation) -export const recordPostSent = internalMutation({ - args: { - postSlug: v.string(), - sentCount: v.number(), - }, - returns: v.id("newsletterSentPosts"), - handler: async (ctx, args) => { - // Check if already recorded - const existing = await ctx.db - .query("newsletterSentPosts") - .withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug)) - .first(); - - if (existing) { - // Update existing record - await ctx.db.patch(existing._id, { - sentAt: Date.now(), - sentCount: args.sentCount, - }); - return existing._id; - } - - // Create new record - return await ctx.db.insert("newsletterSentPosts", { - postSlug: args.postSlug, - sentAt: Date.now(), - sentCount: args.sentCount, - }); - }, -}); - -// Send newsletter for a post (internal action) -export const sendPostNewsletter = internalAction({ - args: { - postSlug: v.string(), - }, - returns: v.object({ - success: v.boolean(), - sentCount: v.number(), - message: v.string(), - }), - handler: async (ctx, args) => { - // Check if already sent - const alreadySent = await ctx.runQuery( - internal.newsletter.hasPostBeenSent, - { - postSlug: args.postSlug, - }, - ); - - if (alreadySent) { - return { - success: false, - sentCount: 0, - message: "This post has already been sent to subscribers.", - }; - } - - // Get post details - const post = await ctx.runQuery(internal.posts.getPostBySlugInternal, { - slug: args.postSlug, - }); - - if (!post || !post.published) { - return { - success: false, - sentCount: 0, - message: "Post not found or not published.", - }; - } - - // Get subscribers - const subscribers = await ctx.runQuery( - internal.newsletter.getActiveSubscribers, - ); - - if (subscribers.length === 0) { - return { - success: false, - sentCount: 0, - message: "No active subscribers.", - }; - } - - // Get AgentMail API key - const apiKey = process.env.AGENTMAIL_API_KEY; - if (!apiKey) { - throw new Error("AGENTMAIL_API_KEY not configured"); - } - - // Build email content - const siteUrl = process.env.SITE_URL || "https://markdown.fast"; - const postUrl = `${siteUrl}/${post.slug}`; - - let emailContent = `

${post.title}

`; - if (post.description) { - emailContent += `

${post.description}

`; - } - if (post.excerpt) { - emailContent += `

${post.excerpt}

`; - } - emailContent += `

Read more →

`; - - // Send emails via AgentMail API - // Note: Using AgentMail SDK when available - let sentCount = 0; - for (const subscriber of subscribers) { - try { - // AgentMail API call would go here - // await agentmailClient.send({ - // to: subscriber.email, - // subject: post.title, - // html: emailContent, - // from: process.env.AGENTMAIL_FROM_EMAIL, - // }); - sentCount++; - } catch (error) { - console.error(`Failed to send to ${subscriber.email}:`, error); - } - } - - // Record that post was sent - await ctx.runMutation(internal.newsletter.recordPostSent, { - postSlug: args.postSlug, - sentCount, - }); - - return { - success: true, - sentCount, - message: `Newsletter sent to ${sentCount} subscribers.`, - }; - }, -}); - -// Internal query to get post by slug (for newsletter) -export const getPostBySlugInternal = internalQuery({ - args: { - slug: v.string(), - }, - returns: v.union( - v.object({ - slug: v.string(), - title: v.string(), - description: v.string(), - excerpt: v.optional(v.string()), - published: v.boolean(), - }), - 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) return null; - - return { - slug: post.slug, - title: post.title, - description: post.description, - excerpt: post.excerpt, - published: post.published, - }; - }, -}); ``` -**File:** `convex/posts.ts`Add internal query for newsletter: - -```typescript -// Add to existing posts.ts file -export const getPostBySlugInternal = internalQuery({ - args: { slug: v.string() }, - returns: v.union(/* same as above */), - handler: async (ctx, args) => { - // Same implementation as in newsletter.ts - }, -}); -``` - -### Step 5: Newsletter Send Script - -**File:** `scripts/send-newsletter.ts`Create script to send newsletters (similar to `sync-posts.ts`): - -```typescript -import { ConvexHttpClient } from "convex/browser"; -import { internal } from "../convex/_generated/api"; -import dotenv from "dotenv"; - -dotenv.config({ path: ".env.local" }); -dotenv.config(); - -const client = new ConvexHttpClient(process.env.VITE_CONVEX_URL!); - -async function sendNewsletterForPost(postSlug: string) { - try { - const result = await client.action(internal.newsletter.sendPostNewsletter, { - postSlug, - }); - - if (result.success) { - console.log(`āœ… ${result.message}`); - console.log(`šŸ“§ Sent to ${result.sentCount} subscribers`); - } else { - console.log(`ā„¹ļø ${result.message}`); - } - } catch (error) { - console.error("āŒ Error sending newsletter:", error); - process.exit(1); - } -} - -// Main execution -const args = process.argv.slice(2); -if (args.length === 0) { - console.error("Usage: npm run newsletter:send -- "); - console.error("Example: npm run newsletter:send -- my-new-post"); - process.exit(1); -} - -const postSlug = args[0]; -sendNewsletterForPost(postSlug); -``` - -### Step 6: Auto-Send Integration with Sync Script - -**File:** `scripts/sync-posts.ts`Modify sync script to detect new posts and auto-send if enabled: - -```typescript -// Add after syncing posts -async function checkAndSendNewPosts( - syncedPosts: ParsedPost[], - client: ConvexHttpClient, -) { - // Check if auto-send is enabled (would need to query config) - // For now, check environment variable - const autoSend = process.env.AGENTMAIL_AUTO_SEND === "true"; - if (!autoSend) return; - - // Get all synced post slugs - const syncedSlugs = syncedPosts.filter((p) => p.published).map((p) => p.slug); - - // Check which posts haven't been sent yet - for (const slug of syncedSlugs) { - try { - const alreadySent = await client.query( - internal.newsletter.hasPostBeenSent, - { postSlug: slug }, - ); - - if (!alreadySent) { - console.log(`\nšŸ“§ Sending newsletter for new post: ${slug}`); - await client.action(internal.newsletter.sendPostNewsletter, { - postSlug: slug, - }); - } - } catch (error) { - console.error(`Error sending newsletter for ${slug}:`, error); - } - } -} - -// Call this after syncPostsPublic in main function -await checkAndSendNewPosts(parsedPosts, client); -``` - -### Step 7: Newsletter Page - -**File:** `content/pages/newsletter.md`Create dedicated newsletter signup page: - -```markdown ---- -title: "Newsletter" -slug: "newsletter" -published: true -order: 5 -showInNav: true --- -Subscribe to get updates about new posts, features, and developments. +### Phase 1C: Frontend Component -[NewsletterSignup component will render here] -``` +**Can work in parallel with Phase 1B** -**File:** `src/pages/Post.tsx`Modify to render NewsletterSignup component for newsletter page: +#### 1C.1: NewsletterSignup Component + +**File:** `src/components/NewsletterSignup.tsx` ```typescript -// In Post.tsx, after checking if it's a page -if (page && page.slug === siteConfig.newsletter.signup.page.slug) { - // Render newsletter signup page - return ( -
- {/* ... existing page rendering ... */} - -
- ); -} -``` - -### Step 8: Unsubscribe Page - -**File:** `src/pages/Unsubscribe.tsx`Create unsubscribe page: - -```typescript -import { useSearchParams, useNavigate } from "react-router-dom"; +import { useState } from "react"; import { useMutation } from "convex/react"; import { api } from "../../convex/_generated/api"; -import { useState, useEffect } from "react"; +import siteConfig from "../config/siteConfig"; -export default function Unsubscribe() { - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const email = searchParams.get("email"); - const token = searchParams.get("token"); - const unsubscribeMutation = useMutation(api.newsletter.unsubscribe); - const [status, setStatus] = useState<"loading" | "success" | "error" | "idle">("idle"); +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(""); - useEffect(() => { - if (email && token) { - handleUnsubscribe(); - } - }, [email, token]); + const subscribe = useMutation(api.newsletter.subscribe); - const handleUnsubscribe = async () => { - if (!email || !token) { + // 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("Invalid unsubscribe link."); + setMessage("Please enter your email."); return; } setStatus("loading"); + try { - const result = await unsubscribeMutation({ email, token }); - setStatus(result.success ? "success" : "error"); - setMessage(result.message); - } catch (error) { + 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("An error occurred. Please try again."); + setMessage("Something went wrong. Please try again."); } }; return ( -
-

Unsubscribe

- {status === "loading" &&

Processing...

} - {status === "success" && ( - <> -

{message}

- - - )} - {status === "error" &&

{message}

} - {status === "idle" && ( -

Use the unsubscribe link from your email to unsubscribe.

- )} -
+
+
+

{displayTitle}

+ {displayDescription && ( +

{displayDescription}

+ )} + + {status === "success" ? ( +

{message}

+ ) : ( +
+ setEmail(e.target.value)} + placeholder="your@email.com" + className="newsletter-signup__input" + disabled={status === "loading"} + aria-label="Email address" + /> + +
+ )} + + {status === "error" && ( +

{message}

+ )} +
+
); } ``` -**File:** `src/App.tsx`Add unsubscribe route: +#### 1C.2: CSS Styling -```typescript -// Add route -} /> -``` - -### Step 9: Integration Points - -**File:** `src/pages/Home.tsx`Add newsletter signup above footer: - -```typescript -// Before footer section -{siteConfig.newsletter.enabled && - siteConfig.newsletter.signup.home.enabled && - siteConfig.newsletter.signup.home.position === "above-footer" && ( - - )} - -{/* Footer section */} -
- {/* ... existing footer ... */} -
-``` - -**File:** `src/pages/Post.tsx`Add newsletter signup below post content (only for posts, not pages): - -```typescript -// After BlogPost content, before footer -{post && // Only for posts, not pages - siteConfig.newsletter.enabled && - siteConfig.newsletter.signup.posts.enabled && ( - - )} - -
- {/* ... existing footer ... */} -
-``` - -### Step 10: Webhook Handler (Future Email-to-Post) - -**File:** `convex/webhooks.ts`Create webhook handler for AgentMail events: - -```typescript -import { httpAction } from "./_generated/server"; -import { internal } from "./_generated/api"; - -export const agentmailWebhook = httpAction(async (ctx, request) => { - // Verify webhook signature from AgentMail - const signature = request.headers.get("x-agentmail-signature"); - // Add signature verification logic here - - const body = await request.json(); - - // Handle different event types - if (body.type === "message.received") { - // Process incoming email for email-to-post workflow - await ctx.runMutation(internal.newsletter.handleIncomingEmail, { - from: body.from, - subject: body.subject, - content: body.content, - attachments: body.attachments || [], - inboxId: body.inbox_id, - }); - } - - return new Response(JSON.stringify({ received: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -}); -``` - -**File:** `convex/newsletter.ts`Add handler for incoming emails: - -```typescript -// Handle incoming email (for email-to-post workflow) -export const handleIncomingEmail = internalMutation({ - args: { - from: v.string(), - subject: v.string(), - content: v.string(), - attachments: v.array(v.string()), - inboxId: v.string(), - }, - returns: v.id("emailDrafts"), - handler: async (ctx, args) => { - // Parse subject for tags like [draft] or [publish] - const subjectLower = args.subject.toLowerCase(); - const isDraft = subjectLower.includes("[draft]"); - const isPublish = subjectLower.includes("[publish]"); - - const status = isPublish ? "published" : isDraft ? "draft" : "draft"; - - // Create draft post - return await ctx.db.insert("emailDrafts", { - from: args.from, - subject: args.subject, - content: args.content, // Markdown content - attachments: args.attachments, - status, - createdAt: Date.now(), - }); - }, -}); -``` - -**File:** `convex/http.ts`Add webhook route: - -```typescript -import { agentmailWebhook } from "./webhooks"; - -// Add route -http.route({ - path: "/webhooks/agentmail", - method: "POST", - handler: agentmailWebhook, -}); -``` - -### Step 11: Package.json and Environment Variables - -**File:** `package.json`Add newsletter scripts: - -```json -{ - "scripts": { - "newsletter:send": "npx tsx scripts/send-newsletter.ts", - "sync": "npx tsx scripts/sync-posts.ts" - } -} -``` - -**File:** `.env.local.example`Add AgentMail environment variables: - -```bash -# AgentMail Configuration (optional) -AGENTMAIL_API_KEY=your_api_key_here -AGENTMAIL_INBOX_USERNAME=newsletter -AGENTMAIL_INBOX_DOMAIN=agentmail.to -AGENTMAIL_AUTO_SEND=false -AGENTMAIL_FROM_EMAIL=newsletter@agentmail.to -UNSUBSCRIBE_SECRET=change-this-in-production -``` - -### Step 12: CSS Styling - -**File:** `src/styles/global.css`Add newsletter component styles matching existing theme: +**File:** `src/styles/global.css` (add to existing file) ```css -/* Newsletter Signup Component */ +/* ═══════════════════════════════════════════════════════════════════════════ + Newsletter Signup Component + ═══════════════════════════════════════════════════════════════════════════ */ + .newsletter-signup { margin: 2rem 0; padding: 1.5rem; @@ -1130,46 +555,67 @@ UNSUBSCRIBE_SECRET=change-this-in-production background: var(--bg-secondary); } +.newsletter-signup__content { + max-width: 480px; + margin: 0 auto; + text-align: center; +} + .newsletter-signup__title { - font-size: var(--font-size-xl); - margin-bottom: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem 0; color: var(--text-primary); } .newsletter-signup__description { - font-size: var(--font-size-base); + font-size: 0.9rem; color: var(--text-secondary); - margin-bottom: 1rem; + margin: 0 0 1rem 0; } .newsletter-signup__form { display: flex; - flex-direction: column; - gap: 0.75rem; + gap: 0.5rem; + justify-content: center; } .newsletter-signup__input { - padding: 0.75rem; - font-size: var(--font-size-base); + flex: 1; + max-width: 280px; + padding: 0.625rem 0.875rem; + font-size: 0.9rem; border: 1px solid var(--border-color); - border-radius: 4px; + 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.75rem 1.5rem; - font-size: var(--font-size-md); - background: var(--accent-color); - color: var(--text-on-accent); + padding: 0.625rem 1.25rem; + font-size: 0.9rem; + font-weight: 500; border: none; - border-radius: 4px; + 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.9; + opacity: 0.85; } .newsletter-signup__button:disabled { @@ -1179,109 +625,413 @@ UNSUBSCRIBE_SECRET=change-this-in-production .newsletter-signup__success { padding: 0.75rem; - background: var(--success-bg, #d4edda); - color: var(--success-text, #155724); - border-radius: 4px; + 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 { - color: var(--error-color, #dc3545); - font-size: var(--font-size-sm); margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--error-color, #ef4444); } -/* Unsubscribe Page */ -.unsubscribe-page { - max-width: 600px; - margin: 2rem auto; - padding: 2rem; -} +/* Mobile responsive */ +@media (max-width: 480px) { + .newsletter-signup__form { + flex-direction: column; + } -.unsubscribe-page h1 { - font-size: var(--font-size-3xl); - margin-bottom: 1rem; -} -``` + .newsletter-signup__input { + max-width: 100%; + } -### Step 13: Fork Configuration - -**File:** `fork-config.json.example`Add newsletter configuration: - -```json -{ - "newsletter": { - "enabled": false, - "agentmail": { - "inboxUsername": "newsletter", - "inboxDomain": "agentmail.to" - }, - "signup": { - "requireName": false, - "showNameOptional": true, - "home": { - "enabled": false, - "position": "above-footer", - "title": "Stay Updated", - "description": "Get new posts delivered to your inbox" - }, - "posts": { - "enabled": false, - "position": "below-content", - "title": "Enjoyed this post?", - "description": "Subscribe for more updates" - }, - "page": { - "enabled": false, - "slug": "newsletter", - "title": "Newsletter", - "description": "Subscribe to get updates" - } - }, - "notifications": { - "autoSendNewPosts": false, - "sendOnSync": false, - "fromName": "Your Site Name", - "fromEmail": "newsletter@agentmail.to" - } + .newsletter-signup__button { + width: 100%; } } ``` -**File:** `FORK_CONFIG.md`Add newsletter configuration section: +--- -```markdown -## Newsletter Configuration +### Phase 1D: Integration Points -The newsletter feature is optional and disabled by default. To enable: +**After component and mutations are ready** -1. Set `newsletter.enabled: true` in `siteConfig.ts` -2. Add `AGENTMAIL_API_KEY` to `.env.local` -3. Configure signup forms in `siteConfig.newsletter.signup` -4. Optionally enable auto-send for new posts +#### 1D.1: Home Page Integration -See `prds/agentmailplan-v1.md` for full documentation. +**File:** `src/pages/Home.tsx` + +```typescript +// Import at top +import NewsletterSignup from "../components/NewsletterSignup"; + +// Add before footer section (find the footer JSX) +{siteConfig.newsletter?.enabled && + siteConfig.newsletter.signup.home.enabled && ( + +)} ``` -## Future Features (Not in Initial Implementation) +#### 1D.2: Blog Page Integration -These features are planned but not included in steps 1-11: +**File:** `src/pages/Blog.tsx` -- Weekly/monthly digest emails (cron jobs) -- Developer notifications (stats summaries, new subscriber alerts) -- Contact form via AgentMail -- Email-to-post workflow (webhook handler created, but full workflow deferred) -- Email replies/comments -- RSS feed validation alerts +```typescript +// Import at top +import NewsletterSignup from "../components/NewsletterSignup"; -These can be added incrementally after the core newsletter functionality is working. +// Add before footer or after posts list +{siteConfig.newsletter?.enabled && + siteConfig.newsletter.signup.blogPage.enabled && ( + +)} +``` + +#### 1D.3: Post Integration with Frontmatter + +**File:** `src/pages/Post.tsx` + +Posts can override the newsletter signup via frontmatter: + +- `newsletter: false` - Hide newsletter on this post +- `newsletter: true` - Show newsletter (even if posts default is false) +- No frontmatter - Use siteConfig default + +```typescript +// 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 && ( + +)} +``` + +**Schema update for posts table:** + +```typescript +// 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` + +```typescript +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 ( +
+

Unsubscribe

+ + {status === "loading" &&

Processing...

} + + {status === "success" && ( + <> +

{message}

+ Back to home + + )} + + {status === "error" && ( +

{message}

+ )} + + {status === "idle" && !email && !token && ( +

Use the unsubscribe link from your email.

+ )} +
+ ); +} +``` + +#### 1E.2: Add Route + +**File:** `src/App.tsx` + +```typescript +import Unsubscribe from "./pages/Unsubscribe"; + +// Add route +} /> +``` + +--- + +## Phase 2: Newsletter Sending (Future) + +After Phase 1 is complete and subscribers are collecting. + +### 2.1: Sent Posts Tracking + +**Schema addition:** + +```typescript +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) + +```typescript +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 = ` +

${post.title}

+

${post.description}

+

Read more

+
+

+ Unsubscribe +

+ `; + + 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` + +```typescript +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 "); + 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 -- [ ] Newsletter signup form appears on home page (above footer) when enabled -- [ ] Newsletter signup form appears on blog posts (below content) when enabled -- [ ] Newsletter signup form appears on dedicated newsletter page -- [ ] Name field shows/hides based on configuration -- [ ] Subscription saves to Convex database +### Phase 1 + +- [x] Newsletter signup appears on homepage when enabled +- [ x Newsletter signup appears on /blog page when enabled +- [x] Newsletter signup appears on posts when enabled +- [x] Frontmatter `newsletter: false` hides signup on specific post +- [ x] Email saves to Convex database +- [ x] Duplicate email shows "already subscribed" message - [ ] Unsubscribe link works with token verification -- [ ] `npm run newsletter:send -- ` sends email to all subscribers -- [ ] Auto-send works during `npm run sync` if enabled +- [x] All color themes display correctly + +### Phase 2 + +- [ ] `npm run newsletter:send ` sends to all subscribers +- [ ] Sent posts are tracked to prevent duplicates +- [ ] Unsubscribe link in email works diff --git a/FORK_CONFIG.md b/FORK_CONFIG.md index 873e99b..0634fa7 100644 --- a/FORK_CONFIG.md +++ b/FORK_CONFIG.md @@ -481,6 +481,169 @@ homepage: { --- +## Newsletter Configuration + +The newsletter feature integrates with AgentMail for email subscriptions and sending. It is disabled by default. + +### Environment Variables + +Set these in the Convex dashboard: + +| Variable | Description | +| -------- | ----------- | +| `AGENTMAIL_API_KEY` | Your AgentMail API key | +| `AGENTMAIL_INBOX` | Your inbox address (e.g., `newsletter@mail.agentmail.to`) | + +### In fork-config.json + +```json +{ + "newsletter": { + "enabled": true, + "agentmail": { + "inbox": "newsletter@mail.agentmail.to" + }, + "signup": { + "home": { + "enabled": true, + "position": "above-footer", + "title": "Stay Updated", + "description": "Get new posts delivered to your inbox." + }, + "blogPage": { + "enabled": true, + "position": "above-footer", + "title": "Subscribe", + "description": "Get notified when new posts are published." + }, + "posts": { + "enabled": true, + "position": "below-content", + "title": "Enjoyed this post?", + "description": "Subscribe for more updates." + } + } + } +} +``` + +### Manual Configuration + +In `src/config/siteConfig.ts`: + +```typescript +newsletter: { + enabled: true, // Master switch for newsletter feature + agentmail: { + inbox: "newsletter@mail.agentmail.to", + }, + signup: { + home: { + enabled: true, + position: "above-footer", // or "below-intro" + title: "Stay Updated", + description: "Get new posts delivered to your inbox.", + }, + blogPage: { + enabled: true, + position: "above-footer", // or "below-posts" + title: "Subscribe", + description: "Get notified when new posts are published.", + }, + posts: { + enabled: true, + position: "below-content", + title: "Enjoyed this post?", + description: "Subscribe for more updates.", + }, + }, +}, +``` + +### Frontmatter Override + +Hide or show newsletter signup on specific posts using frontmatter: + +```yaml +--- +title: My Post +newsletter: false # Hide newsletter signup on this post +--- +``` + +Or force show it even if posts default is disabled: + +```yaml +--- +title: Special Offer Post +newsletter: true # Show newsletter signup on this post +--- +``` + +### Sending Newsletters + +To send a newsletter for a specific post: + +```bash +npm run newsletter:send setup-guide +``` + +Or use the Convex CLI directly: + +```bash +npx convex run newsletter:sendPostNewsletter '{"postSlug":"setup-guide","siteUrl":"https://yoursite.com","siteName":"Your Site"}' +``` + +### Subscriber Management + +View subscriber count on the `/stats` page. Subscribers are stored in the `newsletterSubscribers` table in Convex. + +--- + +## Contact Form Configuration + +Enable contact forms on any page or post via frontmatter. Messages are sent via AgentMail. + +### Environment Variables + +Set these in the Convex dashboard: + +| Variable | Description | +| -------- | ----------- | +| `AGENTMAIL_API_KEY` | Your AgentMail API key | +| `AGENTMAIL_INBOX` | Your inbox address for sending (e.g., `newsletter@mail.agentmail.to`) | +| `AGENTMAIL_CONTACT_EMAIL` | Optional: recipient for contact form messages (defaults to AGENTMAIL_INBOX) | + +### Site Config + +In `src/config/siteConfig.ts`: + +```typescript +contactForm: { + enabled: true, // Global toggle for contact form feature + title: "Get in Touch", + description: "Send us a message and we'll get back to you.", +}, +``` + +**Note:** Recipient email is configured via Convex environment variables (`AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX`). Never hardcode email addresses in code. + +### Frontmatter Usage + +Enable contact form on any page or post: + +```yaml +--- +title: Contact Us +slug: contact +contactForm: true +--- +``` + +The form includes name, email, and message fields. Submissions are stored in Convex and sent via AgentMail to the configured recipient. + +--- + ## AI Agent Prompt Copy this prompt to have an AI agent apply all changes: diff --git a/TASK.md b/TASK.md index 091873b..9a01b69 100644 --- a/TASK.md +++ b/TASK.md @@ -7,10 +7,19 @@ ## Current Status -v1.35.0 ready. Added `showImageAtTop` frontmatter field to display images at the top of posts and pages above the header. Image appears full-width when enabled, otherwise only used for Open Graph and featured cards. +v1.38.0 ready. Improved newsletter CLI commands - `newsletter:send` now calls mutation directly and added `newsletter:send:stats` for sending weekly stats summary. Created blog post "How to use AgentMail with Markdown Sync" with complete setup guide. ## Completed +- [x] Newsletter CLI improvements + - [x] Updated newsletter:send to call scheduleSendPostNewsletter mutation directly + - [x] Added newsletter:send:stats command for weekly stats summary + - [x] Created scheduleSendStatsSummary mutation in convex/newsletter.ts + - [x] Created send-newsletter-stats.ts script + - [x] Verified all AgentMail features use environment variables (no hardcoded emails) + - [x] Updated documentation (docs.md, files.md, changelog.md, changelog-page.md, TASK.md) + - [x] Created blog post "How to use AgentMail with Markdown Sync" + - [x] showImageAtTop frontmatter field for posts and pages - [x] Added showImageAtTop optional boolean field to convex/schema.ts for posts and pages - [x] Updated scripts/sync-posts.ts to parse showImageAtTop from frontmatter diff --git a/changelog.md b/changelog.md index 54ddcf0..abefd92 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,119 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.38.0] - 2025-12-27 + +### Added + +- Newsletter CLI improvements + - `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly + - New `newsletter:send:stats` command to send weekly stats summary + - Both commands provide clear success/error feedback +- New mutation `scheduleSendStatsSummary` for CLI stats sending +- Blog post: "How to use AgentMail with Markdown Sync" with complete setup guide + +### Changed + +- `scripts/send-newsletter.ts`: Now calls mutation directly instead of printing instructions +- `convex/newsletter.ts`: Added `scheduleSendStatsSummary` mutation + +### Technical + +- New script: `scripts/send-newsletter-stats.ts` +- All AgentMail features verified to use environment variables (no hardcoded emails) + +## [1.37.0] - 2025-12-27 + +### Added + +- Newsletter Admin UI at `/newsletter-admin` + - Three-column layout similar to Write page + - View all subscribers with search and filter (all/active/unsubscribed) + - Stats showing active, total, and sent newsletter counts + - Delete subscribers directly from admin + - Send newsletter panel with two modes: + - Send Post: Select a blog post to send as newsletter + - Write Email: Compose custom email with markdown support + - Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists) + - Copy icon on success messages to copy CLI commands + - Theme-aware success/error styling (no hardcoded green) + - Recent newsletters list showing sent history + - Configurable via `siteConfig.newsletterAdmin` +- Weekly Digest automation + - Cron job runs every Sunday at 9:00 AM UTC + - Automatically sends all posts published in the last 7 days + - Uses AgentMail SDK for email delivery + - Configurable via `siteConfig.weeklyDigest` +- Developer Notifications + - New subscriber alerts sent via email when someone subscribes + - Weekly stats summary sent every Monday at 9:00 AM UTC + - Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient + - Configurable via `siteConfig.newsletterNotifications` +- Admin queries and mutations for newsletter management + - `getAllSubscribers`: Paginated subscriber list with search/filter + - `deleteSubscriber`: Remove subscriber from database + - `getNewsletterStats`: Stats for admin dashboard + - `getPostsForNewsletter`: List of posts with sent status + +### Changed + +- `convex/newsletter.ts`: Added admin queries (getAllSubscribers, deleteSubscriber, getNewsletterStats, getPostsForNewsletter, getStatsForSummary) and scheduleSendCustomNewsletter mutation +- `convex/newsletterActions.ts`: Added sendWeeklyDigest, notifyNewSubscriber, sendWeeklyStatsSummary, sendCustomNewsletter actions with markdown-to-HTML conversion +- `convex/posts.ts`: Added getRecentPostsInternal query for weekly digest +- `convex/crons.ts`: Added weekly digest (Sunday 9am) and stats summary (Monday 9am) cron jobs +- `src/config/siteConfig.ts`: Added NewsletterAdminConfig, NewsletterNotificationsConfig, WeeklyDigestConfig interfaces +- `src/App.tsx`: Added /newsletter-admin route +- `src/styles/global.css`: Added newsletter admin styles with responsive design + +### Technical + +- New page: `src/pages/NewsletterAdmin.tsx` +- Newsletter admin hidden from navigation by default (security through obscurity) +- All admin features togglable via siteConfig +- Uses Convex internal actions for email sending (Node.js runtime with AgentMail SDK) +- Cron jobs use environment variables: SITE_URL, SITE_NAME + +## [1.36.0] - 2025-12-27 + +### Added + +- Social footer component with customizable social links and copyright + - Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more) + - Shows copyright symbol, site name, and auto-updating year on the right + - Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts` + - Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website + - Uses Phosphor icons for consistent styling + - Appears below the main footer on homepage, blog posts, and pages + - Can work independently of the main footer when set via frontmatter +- Frontmatter control for social footer visibility + - `showSocialFooter` field for posts and pages to override siteConfig defaults + - Set `showSocialFooter: false` to hide on specific posts/pages + - Works like existing `showFooter` field pattern +- Social footer configuration options + - `enabled`: Global toggle for social footer + - `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility + - `socialLinks`: Array of social link objects with platform and URL + - `copyright.siteName`: Site/company name for copyright display + - `copyright.showYear`: Toggle for auto-updating year + +### Changed + +- `src/config/siteConfig.ts`: Added `SocialLink`, `SocialFooterConfig` interfaces and `socialFooter` configuration +- `convex/schema.ts`: Added `showSocialFooter` optional boolean field to posts and pages tables +- `convex/posts.ts` and `convex/pages.ts`: Updated queries and mutations to include `showSocialFooter` field +- `scripts/sync-posts.ts`: Updated to parse `showSocialFooter` from frontmatter for both posts and pages +- `src/pages/Home.tsx`: Added SocialFooter component below Footer +- `src/pages/Post.tsx`: Added SocialFooter component below Footer for both posts and pages +- `src/pages/Blog.tsx`: Added SocialFooter component below Footer +- `src/styles/global.css`: Added social footer styles with flexbox layout and mobile responsive design + +### Technical + +- New component: `src/components/SocialFooter.tsx` +- Uses Phosphor icons: GithubLogo, TwitterLogo, LinkedinLogo, InstagramLogo, YoutubeLogo, TiktokLogo, DiscordLogo, Globe +- Responsive design: stacks vertically on mobile (max-width: 480px) +- Year automatically updates using `new Date().getFullYear()` + ## [1.35.0] - 2025-12-26 ### Added diff --git a/content/blog/how-to-use-agentmail.md b/content/blog/how-to-use-agentmail.md new file mode 100644 index 0000000..41442c7 --- /dev/null +++ b/content/blog/how-to-use-agentmail.md @@ -0,0 +1,240 @@ +--- +title: "How to use AgentMail with Markdown Sync" +description: "Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog" +date: "2025-12-27" +slug: "how-to-use-agentmail" +published: true +tags: ["agentmail", "newsletter", "email", "setup"] +--- + +AgentMail provides email infrastructure for your markdown blog, enabling newsletter subscriptions, contact forms, and automated email notifications. This guide covers setup, configuration, and usage. + +## What is AgentMail + +AgentMail is an email service designed for AI agents and developers. It handles email sending and receiving without OAuth or MFA requirements, making it ideal for automated workflows. + +For this markdown blog framework, AgentMail powers: + +- Newsletter subscriptions and sending +- Contact forms on posts and pages +- Developer notifications for new subscribers +- Weekly digest emails +- Weekly stats summaries + +## Setup + +### 1. Create an AgentMail account + +Sign up at [agentmail.to](https://agentmail.to) and create an inbox. Your inbox address will look like `yourname@agentmail.to`. + +### 2. Get your API key + +In the AgentMail dashboard, navigate to API settings and copy your API key. You'll need this for Convex environment variables. + +### 3. Configure Convex environment variables + +In your Convex dashboard, go to Settings > Environment Variables and add: + +| Variable | Description | Required | +|----------|-------------|----------| +| `AGENTMAIL_API_KEY` | Your AgentMail API key | Yes | +| `AGENTMAIL_INBOX` | Your inbox address (e.g., `markdown@agentmail.to`) | Yes | +| `AGENTMAIL_CONTACT_EMAIL` | Contact form recipient (defaults to inbox if not set) | No | + +**Important:** Never hardcode email addresses in your code. Always use environment variables. + +### 4. Enable features in siteConfig + +Edit `src/config/siteConfig.ts` to enable newsletter and contact form features: + +```typescript +newsletter: { + enabled: true, + showOnHomepage: true, + showOnBlogPage: true, + showOnPosts: true, + title: "Subscribe to the newsletter", + description: "Get updates delivered to your inbox", +}, + +contactForm: { + enabled: true, + title: "Get in touch", + description: "Send us a message", +}, +``` + +## Newsletter features + +### Subscriber management + +The Newsletter Admin page at `/newsletter-admin` provides: + +- View all subscribers with search and filters +- Delete subscribers +- Send blog posts as newsletters +- Write and send custom emails with markdown support +- View email statistics dashboard +- Track recent sends (last 10) + +### Sending newsletters + +**Via CLI:** + +```bash +# Send a specific post to all subscribers +npm run newsletter:send setup-guide + +# Send weekly stats summary to your inbox +npm run newsletter:send:stats +``` + +**Via Admin UI:** + +1. Navigate to `/newsletter-admin` +2. Select "Send Post" or "Write Email" from the sidebar +3. Choose a post or compose a custom email +4. Click "Send Newsletter" + +### Weekly digest + +Automated weekly digest emails are sent every Sunday at 9:00 AM UTC. They include all posts published in the last 7 days. + +Configure in `siteConfig.ts`: + +```typescript +weeklyDigest: { + enabled: true, +}, +``` + +### Developer notifications + +Receive email notifications when: + +- A new subscriber signs up +- Weekly stats summary (every Monday at 9:00 AM UTC) + +Configure in `siteConfig.ts`: + +```typescript +newsletterNotifications: { + enabled: true, +}, +``` + +Notifications are sent to `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` if contact email is not set. + +## Contact forms + +### Enable on posts and pages + +Add `contactForm: true` to any post or page frontmatter: + +```markdown +--- +title: "Contact Us" +slug: "contact" +published: true +contactForm: true +--- + +Your page content here... +``` + +The contact form includes: + +- Name field +- Email field +- Message field + +Submissions are stored in Convex and sent via AgentMail to your configured recipient. + +### Frontmatter options + +| Field | Type | Description | +|-------|------|-------------| +| `contactForm` | boolean | Enable contact form on this post/page | + +## Frontmatter options + +### Newsletter signup + +Control newsletter signup display per post/page: + +```markdown +--- +title: "My Post" +newsletter: true # Show signup (default: follows siteConfig) +--- +``` + +Or hide it: + +```markdown +--- +title: "My Post" +newsletter: false # Hide signup even if enabled globally +--- +``` + +## Environment variables + +All AgentMail features require these Convex environment variables: + +**Required:** + +- `AGENTMAIL_API_KEY` - Your AgentMail API key +- `AGENTMAIL_INBOX` - Your inbox address + +**Optional:** + +- `AGENTMAIL_CONTACT_EMAIL` - Contact form recipient (defaults to inbox) + +**Note:** If environment variables are not configured, users will see: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." + +## CLI commands + +| Command | Description | +|---------|-------------| +| `npm run newsletter:send ` | Send a blog post to all subscribers | +| `npm run newsletter:send:stats` | Send weekly stats summary to your inbox | + +## Troubleshooting + +**Emails not sending:** + +1. Verify `AGENTMAIL_API_KEY` and `AGENTMAIL_INBOX` are set in Convex dashboard +2. Check Convex function logs for error messages +3. Ensure your inbox is active in AgentMail dashboard + +**Contact form not appearing:** + +1. Verify `contactForm: true` is in frontmatter +2. Check `siteConfig.contactForm.enabled` is `true` +3. Run `npm run sync` to sync frontmatter changes + +**Newsletter Admin not accessible:** + +1. Verify `siteConfig.newsletterAdmin.enabled` is `true` +2. Navigate to `/newsletter-admin` directly (hidden from nav by default) + +## Resources + +- [AgentMail Documentation](https://docs.agentmail.to) +- [AgentMail Quickstart](https://docs.agentmail.to/quickstart) +- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email) +- [AgentMail Inboxes](https://docs.agentmail.to/inboxes) + +## Summary + +AgentMail integration provides: + +- Newsletter subscriptions and sending +- Contact forms on any post or page +- Automated weekly digests +- Developer notifications +- Admin UI for subscriber management +- CLI tools for sending newsletters and stats + +All features use Convex environment variables for configuration. No hardcoded emails in your codebase. diff --git a/content/blog/how-to-use-firecrawl.md b/content/blog/how-to-use-firecrawl.md new file mode 100644 index 0000000..19405dd --- /dev/null +++ b/content/blog/how-to-use-firecrawl.md @@ -0,0 +1,64 @@ +--- +title: "How to use Firecrawl" +description: "Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat." +date: "2025-01-20" +slug: "how-to-use-firecrawl" +published: true +tags: ["tutorial", "firecrawl", "import"] +--- + +# How to use Firecrawl + +You found an article you want to republish or reference. Copying content manually takes time. Firecrawl scrapes web pages and converts them to markdown automatically. + +## What it is + +Firecrawl is a web scraping service that turns any URL into clean markdown. This app uses it in two places: the import script for creating draft posts, and the AI chat feature for fetching page content. + +## Who it's for + +Developers who want to import external articles without manual copying. If you republish content or need to reference external sources, Firecrawl saves time. + +## The problem it solves + +Manually copying content from websites is slow. You copy text, fix formatting, add frontmatter, and handle images. Firecrawl does this automatically. + +## How it works + +The import script scrapes a URL, extracts the title and description, converts HTML to markdown, and creates a draft post in `content/blog/`. The AI chat feature uses Firecrawl to fetch page content when you share URLs in conversations. + +## How to try it + +**Step 1: Get your API key** + +Visit [firecrawl.dev](https://firecrawl.dev) and sign up. Copy your API key. It starts with `fc-`. + +**Step 2: Set up local imports** + +Add the key to `.env.local` in your project root: + +``` +FIRECRAWL_API_KEY=fc-your-api-key-here +``` + +Now you can import articles: + +```bash +npm run import https://example.com/article +``` + +This creates a draft post in `content/blog/`. Review it, set `published: true`, then run `npm run sync`. + +**Step 3: Enable AI chat scraping** + +If you use the AI chat feature, set the same key in your Convex Dashboard: + +1. Go to [dashboard.convex.dev](https://dashboard.convex.dev) +2. Select your project +3. Open Settings > Environment Variables +4. Add `FIRECRAWL_API_KEY` with your key value +5. Deploy: `npx convex deploy` + +The AI chat can now fetch content from URLs you share. + +That's it. One API key, two places to set it, and you're done. diff --git a/content/blog/setup-guide.md b/content/blog/setup-guide.md index d21b93f..8789dbf 100644 --- a/content/blog/setup-guide.md +++ b/content/blog/setup-guide.md @@ -8,6 +8,7 @@ tags: ["convex", "netlify", "tutorial", "deployment"] readTime: "8 min read" featured: true featuredOrder: 6 +newsletter: true layout: "sidebar" image: "/images/setupguide.png" authorName: "Markdown" @@ -62,6 +63,7 @@ This guide walks you through forking [this markdown framework](https://github.co - [Visitor Map](#visitor-map) - [Logo Gallery](#logo-gallery) - [Blog page](#blog-page) + - [Hardcoded Navigation Items](#hardcoded-navigation-items) - [Scroll-to-top button](#scroll-to-top-button) - [Change the Default Theme](#change-the-default-theme) - [Change the Font](#change-the-font) @@ -83,6 +85,7 @@ This guide walks you through forking [this markdown framework](https://github.co - [Build failures on Netlify](#build-failures-on-netlify) - [Project Structure](#project-structure) - [Write Page](#write-page) + - [AI Agent chat](#ai-agent-chat) - [Next Steps](#next-steps) ## Prerequisites @@ -187,6 +190,7 @@ export default defineSchema({ Blog posts live in `content/blog/` as markdown files. Sync them to Convex: **Development:** + ```bash npm run sync # Sync markdown content npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt) @@ -194,6 +198,7 @@ npm run sync:all # Sync content + discovery files together ``` **Production:** + ```bash npm run sync:prod # Sync markdown content npm run sync:discovery:prod # Update discovery files @@ -330,21 +335,21 @@ Your markdown content here... ### Frontmatter Fields -| Field | Required | Description | -| --------------- | -------- | ----------------------------------------- | -| `title` | Yes | Post title | -| `description` | Yes | Short description for SEO | -| `date` | Yes | Publication date (YYYY-MM-DD) | -| `slug` | Yes | URL path (must be unique) | -| `published` | Yes | Set to `true` to publish | -| `tags` | Yes | Array of topic tags | -| `readTime` | No | Estimated reading time | -| `image` | No | Header/Open Graph image URL | -| `excerpt` | No | Short excerpt for card view | -| `featured` | No | Set `true` to show in featured section | -| `featuredOrder` | No | Order in featured section (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | +| Field | Required | Description | +| --------------- | -------- | ----------------------------------------------------------------------------- | +| `title` | Yes | Post title | +| `description` | Yes | Short description for SEO | +| `date` | Yes | Publication date (YYYY-MM-DD) | +| `slug` | Yes | URL path (must be unique) | +| `published` | Yes | Set to `true` to publish | +| `tags` | Yes | Array of topic tags | +| `readTime` | No | Estimated reading time | +| `image` | No | Header/Open Graph image URL | +| `excerpt` | No | Short excerpt for card view | +| `featured` | No | Set `true` to show in featured section | +| `featuredOrder` | No | Order in featured section (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | | `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | ### How Frontmatter Works @@ -409,6 +414,7 @@ The `npm run sync` command only syncs markdown text content. Images are deployed After adding or editing posts, sync to Convex. **Development sync:** + ```bash npm run sync # Sync markdown content npm run sync:discovery # Update discovery files @@ -426,6 +432,7 @@ VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud Get your production URL from the [Convex Dashboard](https://dashboard.convex.dev) by selecting your project and switching to the Production deployment. Then sync: + ```bash npm run sync:prod # Sync markdown content npm run sync:discovery:prod # Update discovery files @@ -443,17 +450,17 @@ Both files are gitignored. Each developer creates their own local environment fi ### When to Sync vs Deploy -| What you're changing | Command | Timing | -| -------------------------------- | -------------------------- | -------------------- | -| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) | -| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) | -| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) | +| What you're changing | Command | Timing | +| -------------------------------- | -------------------------- | ----------------------- | +| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) | +| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) | +| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) | | Site config changes | `npm run sync:discovery` | Updates discovery files | -| Import external URL | `npm run import` then sync | Instant (no rebuild) | -| Images in `public/images/` | Git commit + push | Requires rebuild | -| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild | -| Logo gallery config | Redeploy | Requires rebuild | -| React components/styles | Redeploy | Requires rebuild | +| Import external URL | `npm run import` then sync | Instant (no rebuild) | +| Images in `public/images/` | Git commit + push | Requires rebuild | +| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild | +| Logo gallery config | Redeploy | Requires rebuild | +| React components/styles | Redeploy | Requires rebuild | **Markdown content** syncs instantly via Convex. **Images and source code** require pushing to GitHub for Netlify to rebuild. @@ -971,15 +978,12 @@ body { serif; /* Monospace */ - font-family: - "IBM Plex Mono", - "Liberation Mono", - ui-monospace, - monospace; + font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace; } ``` Available font options: + - `serif`: New York serif font (default) - `sans`: System sans-serif fonts - `monospace`: IBM Plex Mono monospace font @@ -1103,6 +1107,49 @@ How it works: - A cron job cleans up stale sessions every 5 minutes - No personal data is stored (only anonymous UUIDs) +## Newsletter Admin + +A newsletter management interface is available at `/newsletter-admin`. Use it to view subscribers, send newsletters, and compose custom emails. + +**Features:** + +- View and search all subscribers with filtering options (search bar in header) +- Delete subscribers from the admin UI +- Send published blog posts as newsletters +- Write custom emails using markdown formatting +- View recent newsletter sends (last 10, tracks both posts and custom emails) +- Email statistics dashboard with comprehensive metrics + +**Setup:** + +1. Enable in `src/config/siteConfig.ts`: + +```typescript +newsletterAdmin: { + enabled: true, + showInNav: false, // Keep hidden, access via direct URL +}, +``` + +2. Set environment variables in Convex Dashboard: + +| Variable | Description | +| ------------------------- | ------------------------------------ | +| `AGENTMAIL_API_KEY` | Your AgentMail API key | +| `AGENTMAIL_INBOX` | Your AgentMail inbox address | +| `AGENTMAIL_CONTACT_EMAIL` | Optional recipient for contact forms | + +**Important:** If environment variables are not configured, users will see an error message when attempting to use newsletter or contact form features: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." + +**Sending newsletters:** + +Two modes are available: + +1. **Send Post**: Select a blog post to send to all active subscribers +2. **Write Email**: Compose custom content with markdown support + +The admin UI shows send results and provides CLI commands as alternatives. + ## Mobile Navigation On mobile and tablet screens (under 768px), a hamburger menu provides navigation. The menu slides out from the left with keyboard navigation (Escape to close) and a focus trap for accessibility. It auto-closes when you navigate to a new route. @@ -1111,23 +1158,23 @@ On mobile and tablet screens (under 768px), a hamburger menu provides navigation Each post and page includes a share dropdown with options for AI tools: -| Option | Description | -| -------------------- | ------------------------------------------------- | -| Copy page | Copies formatted markdown to clipboard | -| Open in ChatGPT | Opens ChatGPT with raw markdown URL | -| Open in Claude | Opens Claude with raw markdown URL | -| Open in Perplexity | Opens Perplexity with raw markdown URL | -| View as Markdown | Opens raw `.md` file in new tab | -| Download as SKILL.md | Downloads skill file for AI agent training | +| Option | Description | +| -------------------- | ------------------------------------------ | +| Copy page | Copies formatted markdown to clipboard | +| Open in ChatGPT | Opens ChatGPT with raw markdown URL | +| Open in Claude | Opens Claude with raw markdown URL | +| Open in Perplexity | Opens Perplexity with raw markdown URL | +| View as Markdown | Opens raw `.md` file in new tab | +| Download as SKILL.md | Downloads skill file for AI agent training | **Git push required for AI links:** The "Open in ChatGPT," "Open in Claude," and "Open in Perplexity" options use GitHub raw URLs to fetch content. For these to work, your content must be pushed to GitHub with `git push`. The `npm run sync` command syncs content to Convex for your live site, but AI services fetch directly from GitHub. -| What you want | Command needed | -| ------------------------------------ | ------------------------------ | -| Content visible on your site | `npm run sync` or `sync:prod` | +| What you want | Command needed | +| ------------------------------------ | ------------------------------------------------- | +| Content visible on your site | `npm run sync` or `sync:prod` | | Discovery files updated | `npm run sync:discovery` or `sync:discovery:prod` | -| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub | -| Both content and discovery | `npm run sync:all` or `sync:all:prod` | +| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub | +| Both content and discovery | `npm run sync:all` or `sync:all:prod` | **Download as SKILL.md** formats the content as an Anthropic Agent Skills file with metadata, triggers, and instructions sections. @@ -1325,7 +1372,7 @@ Enable Agent in the right sidebar on individual posts or pages using the `aiChat --- title: "My Post" rightSidebar: true -aiChat: true # Enable Agent in right sidebar +aiChat: true # Enable Agent in right sidebar --- ``` diff --git a/content/pages/changelog-page.md b/content/pages/changelog-page.md index 18844f1..b746399 100644 --- a/content/pages/changelog-page.md +++ b/content/pages/changelog-page.md @@ -9,6 +9,161 @@ layout: "sidebar" All notable changes to this project. ![](https://img.shields.io/badge/License-MIT-yellow.svg) +## v1.38.0 + +Released December 27, 2025 + +**Newsletter CLI improvements** + +- `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly + - Sends emails in the background instead of printing instructions + - Provides clear success/error feedback + - Shows helpful messages about checking Newsletter Admin for results +- New `newsletter:send:stats` command + - Sends weekly stats summary to your inbox on demand + - Uses `scheduleSendStatsSummary` mutation + - Email sent to AGENTMAIL_INBOX or AGENTMAIL_CONTACT_EMAIL +- New mutation `scheduleSendStatsSummary` in `convex/newsletter.ts` + - Allows CLI to trigger stats summary sending + - Schedules `sendWeeklyStatsSummary` internal action + +**Documentation** + +- Blog post: "How to use AgentMail with Markdown Sync" + - Complete setup guide for AgentMail integration + - Environment variables configuration + - Newsletter and contact form features + - CLI commands documentation + - Troubleshooting section +- Updated docs.md with new CLI commands +- Updated files.md with new script reference +- Verified all AgentMail features use environment variables (no hardcoded emails) + +Updated files: `scripts/send-newsletter.ts`, `scripts/send-newsletter-stats.ts`, `convex/newsletter.ts`, `package.json`, `content/blog/how-to-use-agentmail.md`, `content/pages/docs.md`, `files.md`, `changelog.md`, `content/pages/changelog-page.md`, `TASK.md` + +## v1.37.0 + +Released December 27, 2025 + +**Newsletter Admin UI** + +- Newsletter Admin UI at `/newsletter-admin` + - Three-column layout similar to Write page + - View all subscribers with search and filter (all/active/unsubscribed) + - Stats showing active, total, and sent newsletter counts + - Delete subscribers directly from admin + - Send newsletter panel with two modes: + - Send Post: Select a blog post to send as newsletter + - Write Email: Compose custom email with markdown support + - Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists) + - Copy icon on success messages to copy CLI commands + - Theme-aware success/error styling (no hardcoded green) + - Recent newsletters list showing sent history + - Configurable via `siteConfig.newsletterAdmin` + +**Weekly Digest automation** + +- Cron job runs every Sunday at 9:00 AM UTC +- Automatically sends all posts published in the last 7 days +- Uses AgentMail SDK for email delivery +- Configurable via `siteConfig.weeklyDigest` + +**Developer Notifications** + +- New subscriber alerts sent via email when someone subscribes +- Weekly stats summary sent every Monday at 9:00 AM UTC +- Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient +- Configurable via `siteConfig.newsletterNotifications` + +**Admin queries and mutations** + +- `getAllSubscribers`: Paginated subscriber list with search/filter +- `deleteSubscriber`: Remove subscriber from database +- `getNewsletterStats`: Stats for admin dashboard +- `getPostsForNewsletter`: List of posts with sent status + +Updated files: `convex/newsletter.ts`, `convex/newsletterActions.ts`, `convex/posts.ts`, `convex/crons.ts`, `src/config/siteConfig.ts`, `src/App.tsx`, `src/styles/global.css`, `src/pages/NewsletterAdmin.tsx` + +## v1.36.0 + +Released December 27, 2025 + +**Social footer component** + +- Social footer component with customizable social links and copyright + - Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more) + - Shows copyright symbol, site name, and auto-updating year on the right + - Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts` + - Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website + - Uses Phosphor icons for consistent styling + - Appears below the main footer on homepage, blog posts, and pages + - Can work independently of the main footer when set via frontmatter + +**Frontmatter control for social footer** + +- `showSocialFooter` field for posts and pages to override siteConfig defaults +- Set `showSocialFooter: false` to hide on specific posts/pages +- Works like existing `showFooter` field pattern + +**Social footer configuration options** + +- `enabled`: Global toggle for social footer +- `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility +- `socialLinks`: Array of social link objects with platform and URL +- `copyright.siteName`: Site/company name for copyright display +- `copyright.showYear`: Toggle for auto-updating year + +Updated files: `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/pages/Home.tsx`, `src/pages/Post.tsx`, `src/pages/Blog.tsx`, `src/styles/global.css`, `src/components/SocialFooter.tsx` + +## v1.35.0 + +Released December 26, 2025 + +**Image support at top of posts and pages** + +- `showImageAtTop` frontmatter field for posts and pages + - Set `showImageAtTop: true` to display the `image` field at the top of the post/page above the header + - Image displays full-width with rounded corners above the post header + - Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails + - Works for both blog posts and static pages + - Image appears above the post header when enabled + +Updated files: `convex/schema.ts`, `scripts/sync-posts.ts`, `convex/posts.ts`, `convex/pages.ts`, `src/pages/Post.tsx`, `src/pages/Write.tsx`, `src/styles/global.css` + +Documentation updated: `content/pages/docs.md`, `content/blog/how-to-publish.md`, `content/blog/using-images-in-posts.md`, `files.md` + +## v1.34.0 + +Released December 26, 2025 + +**Blog page featured layout with hero post** + +- `blogFeatured` frontmatter field for posts to mark as featured on blog page + - First `blogFeatured` post displays as hero card with landscape image, tags, date, title, excerpt, author info, and read more link + - Remaining `blogFeatured` posts display in 2-column featured row with excerpts + - Regular (non-featured) posts display in 3-column grid without excerpts + - New `BlogHeroCard` component (`src/components/BlogHeroCard.tsx`) for hero display + - New `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true` sorted by date + - `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop + - Card images use 16:10 landscape aspect ratio + - Footer support on blog page via `siteConfig.footer.showOnBlogPage` + +Updated files: `convex/schema.ts`, `convex/posts.ts`, `scripts/sync-posts.ts`, `src/pages/Blog.tsx`, `src/components/PostList.tsx`, `src/styles/global.css` + +## v1.33.1 + +Released December 26, 2025 + +**Article centering in sidebar layouts** + +- Article content now centers in the middle column when sidebars are present + - Left sidebar stays flush left, right sidebar stays flush right + - Article uses `margin-left: auto; margin-right: auto` within its `1fr` grid column + - Works with both two-column (left sidebar only) and three-column (both sidebars) layouts + - Consistent `max-width: 800px` for article content across all sidebar configurations + +Updated files: `src/styles/global.css` + ## v1.33.0 Released December 26, 2025 diff --git a/content/pages/contact.md b/content/pages/contact.md index a0b9fda..f3376b9 100644 --- a/content/pages/contact.md +++ b/content/pages/contact.md @@ -2,42 +2,17 @@ title: "Contact" slug: "contact" published: true +contactForm: false +newsletter: false order: 4 --- You found the contact page. Nice + + ## The technical way This site runs on Convex, which means every page view is a live subscription to the database. You are not reading cached HTML. You are reading data that synced moments ago. If you want to reach out, here is an idea: fork this repo, add a contact form, wire it to a Convex mutation, and deploy. Your message will hit the database in under 100ms. No email server required. - -```typescript -// A contact form mutation looks like this -export const submitContact = mutation({ - args: { - name: v.string(), - email: v.string(), - message: v.string(), - }, - handler: async (ctx, args) => { - await ctx.db.insert("messages", { - ...args, - createdAt: Date.now(), - }); - }, -}); -``` - -## The human way - -Open an issue on GitHub. Or find the author on X. Or send a carrier pigeon. Convex does not support those yet, but the team is probably working on it. - -## Why Convex - -Traditional backends make you write API routes, manage connections, handle caching, and pray nothing breaks at 3am. Convex handles all of that. You write functions. They run in the cloud. Data syncs to clients. Done. - -The contact form example above is the entire backend. No Express. No database drivers. No WebSocket setup. Just a function that inserts a row. - -That is why this site uses Convex. diff --git a/content/pages/docs.md b/content/pages/docs.md index 602f4f1..abacd88 100644 --- a/content/pages/docs.md +++ b/content/pages/docs.md @@ -6,7 +6,7 @@ order: 0 layout: "sidebar" rightSidebar: true aiChat: true -footer: true +showFooter: true --- ## Getting Started @@ -102,23 +102,31 @@ image: "/images/og-image.png" Content here... ``` -| Field | Required | Description | -| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | -| `title` | Yes | Post title | -| `description` | Yes | SEO description | -| `date` | Yes | YYYY-MM-DD format | -| `slug` | Yes | URL path (unique) | -| `published` | Yes | `true` to show | -| `tags` | Yes | Array of strings | -| `readTime` | No | Display time estimate | -| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax | -| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) | -| `excerpt` | No | Short text for card view | -| `featured` | No | `true` to show in featured section | -| `featuredOrder` | No | Order in featured (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| Field | Required | Description | +| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `title` | Yes | Post title | +| `description` | Yes | SEO description | +| `date` | Yes | YYYY-MM-DD format | +| `slug` | Yes | URL path (unique) | +| `published` | Yes | `true` to show | +| `tags` | Yes | Array of strings | +| `readTime` | No | Display time estimate | +| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax | +| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) | +| `excerpt` | No | Short text for card view | +| `featured` | No | `true` to show in featured section | +| `featuredOrder` | No | Order in featured (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `showFooter` | No | Show footer on this post (overrides siteConfig default) | +| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) | +| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) | +| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) | +| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | +| `contactForm` | No | Enable contact form on this post | ### Static pages @@ -135,22 +143,28 @@ order: 1 Content here... ``` -| Field | Required | Description | -| --------------- | -------- | ----------------------------------------------------------------------------- | -| `title` | Yes | Nav link text | -| `slug` | Yes | URL path | -| `published` | Yes | `true` to show | -| `order` | No | Nav order (lower = first) | -| `showInNav` | No | Show in navigation menu (default: `true`) | -| `excerpt` | No | Short text for card view | -| `image` | No | Thumbnail for featured card view | -| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) | -| `featured` | No | `true` to show in featured section | -| `featuredOrder` | No | Order in featured (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | -| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| Field | Required | Description | +| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `title` | Yes | Nav link text | +| `slug` | Yes | URL path | +| `published` | Yes | `true` to show | +| `order` | No | Nav order (lower = first) | +| `showInNav` | No | Show in navigation menu (default: `true`) | +| `excerpt` | No | Short text for card view | +| `image` | No | Thumbnail for featured card view | +| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) | +| `featured` | No | `true` to show in featured section | +| `featuredOrder` | No | Order in featured (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `showFooter` | No | Show footer on this page (overrides siteConfig default) | +| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) | +| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) | +| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | +| `contactForm` | No | Enable contact form on this page | **Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. @@ -803,11 +817,86 @@ The `/stats` page displays real-time analytics: All stats update automatically via Convex subscriptions. +## Newsletter Admin + +The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subscribers and sending newsletters. + +**Features:** + +- View and search all subscribers (search bar in header) +- Filter by status (all, active, unsubscribed) +- Delete subscribers +- Send blog posts as newsletters +- Write and send custom emails with markdown support +- View recent newsletter sends (last 10, includes both posts and custom emails) +- Email statistics dashboard with: + - Total emails sent + - Newsletters sent count + - Active subscribers + - Retention rate + - Detailed summary table + +**Configuration:** + +Enable in `src/config/siteConfig.ts`: + +```typescript +newsletterAdmin: { + enabled: true, // Enable /newsletter-admin route + showInNav: false, // Hide from navigation (access via direct URL) +}, +``` + +**Environment Variables (Convex):** + +| Variable | Description | +| ------------------------- | --------------------------------------------------- | +| `AGENTMAIL_API_KEY` | Your AgentMail API key | +| `AGENTMAIL_INBOX` | Your AgentMail inbox (e.g., `inbox@agentmail.to`) | +| `AGENTMAIL_CONTACT_EMAIL` | Optional contact form recipient (defaults to inbox) | + +**Note:** If environment variables are not configured, users will see the error message: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." when attempting to send newsletters or use contact forms. + +**Sending Newsletters:** + +The admin UI supports two sending modes: + +1. **Send Post**: Select a published blog post to send as a newsletter +2. **Write Email**: Compose a custom email with markdown formatting + +Custom emails support markdown syntax: + +- `# Heading` for headers +- `**bold**` and `*italic*` for emphasis +- `[link text](url)` for links +- `- item` for bullet lists + +**CLI Commands:** + +You can send newsletters via command line: + +```bash +# Send a blog post to all subscribers +npm run newsletter:send + +# Send weekly stats summary to your inbox +npm run newsletter:send:stats +``` + +Example: + +```bash +npm run newsletter:send setup-guide +``` + +The `newsletter:send` command calls the `scheduleSendPostNewsletter` mutation directly and sends emails in the background. Check the Newsletter Admin page or recent sends to see results. + ## API endpoints | Endpoint | Description | | ------------------------------ | --------------------------- | | `/stats` | Real-time analytics | +| `/newsletter-admin` | Newsletter management UI | | `/rss.xml` | RSS feed (descriptions) | | `/rss-full.xml` | RSS feed (full content) | | `/sitemap.xml` | XML sitemap | diff --git a/content/pages/newsletter.md b/content/pages/newsletter.md new file mode 100644 index 0000000..15483f6 --- /dev/null +++ b/content/pages/newsletter.md @@ -0,0 +1,30 @@ +--- +title: Newsletter +slug: newsletter +published: true +order: 15 +showInNav: true +newsletter: true +--- + +# Newsletter + +Stay updated with the latest posts and updates from the markdown sync framework. + +## What you will get + +When you subscribe, you will receive: + +- Notifications when new blog posts are published +- Updates about new features and improvements +- Tips and tricks for getting the most out of markdown sync + +## Subscribe + +Use the form below to subscribe to our newsletter. We respect your privacy and you can unsubscribe at any time. + +## Privacy + +We only use your email address to send you newsletter updates. We never share your email with third parties or use it for any other purpose. + +To unsubscribe, click the unsubscribe link at the bottom of any newsletter email. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9686330..d2f141d 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/contact.ts b/convex/contact.ts new file mode 100644 index 0000000..cb55d56 --- /dev/null +++ b/convex/contact.ts @@ -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; + }, +}); diff --git a/convex/contactActions.ts b/convex/contactActions.ts new file mode 100644 index 0000000..a7297f1 --- /dev/null +++ b/convex/contactActions.ts @@ -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 = ` +
+

New Contact Form Submission

+ + + + + + + + + + + + + +
From:${escapeHtml(args.name)}
Email:${escapeHtml(args.email)}
Source:${escapeHtml(args.source)}
+

Message:

+
${escapeHtml(args.message)}
+
+ `; + + // 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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/convex/crons.ts b/convex/crons.ts index 84a6de9..14de24d 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -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; diff --git a/convex/http.ts b/convex/http.ts index ef9a22b..46c9609 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -41,7 +41,7 @@ http.route({ `, // All posts ...posts.map( - (post) => ` + (post: { slug: string; date: string }) => ` ${SITE_URL}/${post.slug} ${post.date} monthly @@ -50,7 +50,7 @@ http.route({ ), // All pages ...pages.map( - (page) => ` + (page: { slug: string }) => ` ${SITE_URL}/${page.slug} monthly 0.7 @@ -58,7 +58,7 @@ http.route({ ), // All tag pages ...tags.map( - (tagInfo) => ` + (tagInfo: { tag: string }) => ` ${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())} weekly 0.6 @@ -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, }); diff --git a/convex/newsletter.ts b/convex/newsletter.ts new file mode 100644 index 0000000..8d838a8 --- /dev/null +++ b/convex/newsletter.ts @@ -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.", + }; + }, +}); diff --git a/convex/newsletterActions.ts b/convex/newsletterActions.ts new file mode 100644 index 0000000..15da3b6 --- /dev/null +++ b/convex/newsletterActions.ts @@ -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, "&") + .replace(//g, ">") + // Headers (must be at start of line) + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold and italic + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Unordered lists + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/(]*>.*<\/li>\n?)+/g, '
      $&
    ') + // Line breaks (double newline = paragraph) + .replace(/\n\n/g, '

    ') + // Single line breaks + .replace(/\n/g, '
    '); + + // Wrap in paragraph if not starting with a block element + if (!html.startsWith('${html}

    `; + } + + 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 = []; + + // 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 = ` +
    +

    ${escapeHtml(post.title)}

    +

    ${escapeHtml(post.description)}

    + ${post.excerpt ? `

    ${escapeHtml(post.excerpt)}

    ` : ""} +

    + Read more +

    +
    +

    + You received this email because you subscribed to ${escapeHtml(siteName)}.
    + Unsubscribe +

    +
    + `; + + // 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, "'"); +} + +// 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 = []; + + // Initialize AgentMail client + const client = new AgentMailClient({ apiKey }); + + // Build email content + const postsHtml = recentPosts + .map( + (post) => ` +
    +

    + ${escapeHtml(post.title)} +

    +

    ${escapeHtml(post.description)}

    +

    ${post.date}

    +
    + ` + ) + .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 = ` +
    +

    Weekly Digest

    +

    ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""} from ${escapeHtml(siteName)}

    + ${postsHtml} +
    +

    + You received this email because you subscribed to ${escapeHtml(siteName)}.
    + Unsubscribe +

    +
    + `; + + 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: ` +
    +

    New Newsletter Subscriber

    +

    + Email: ${escapeHtml(args.email)}
    + Source: ${escapeHtml(args.source)}
    + Time: ${timestamp} +

    +
    +

    + This is an automated notification from ${escapeHtml(siteName)}. +

    +
    + `, + 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: ` +
    +

    Weekly Newsletter Stats

    +
    +

    + Active Subscribers: ${stats.activeSubscribers}
    + Total Subscribers: ${stats.totalSubscribers}
    + New This Week: ${stats.newThisWeek}
    + Unsubscribed: ${stats.unsubscribedCount}
    + Newsletters Sent: ${stats.totalNewslettersSent} +

    +
    +
    +

    + This is an automated weekly summary from ${escapeHtml(siteName)}. +

    +
    + `, + 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 = []; + + // 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 = ` +
    + ${contentHtml} +
    +

    + You received this email because you subscribed to ${escapeHtml(siteName)}.
    + Unsubscribe +

    +
    + `; + + 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 }; + }, +}); + diff --git a/convex/pages.ts b/convex/pages.ts index 821a31b..d7d7faa 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -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++; diff --git a/convex/posts.ts b/convex/posts.ts index ef00afd..a7af0db 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -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++; diff --git a/convex/rss.ts b/convex/rss.ts index ea37904..44775d0 100644 --- a/convex/rss.ts +++ b/convex/rss.ts @@ -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, }); diff --git a/convex/schema.ts b/convex/schema.ts index 9c8942e..ae41880 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"]), }); diff --git a/files.md b/files.md index 9d80c08..32dc4ab 100644 --- a/files.md +++ b/files.md @@ -33,7 +33,7 @@ A brief description of each file in the codebase. | File | Description | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration, homepage configuration, AI chat configuration) | +| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration, social footer configuration, homepage configuration, AI chat configuration, newsletter configuration, contact form configuration) | ### Pages (`src/pages/`) @@ -45,6 +45,7 @@ A brief description of each file in the codebase. | `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars | | `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence | | `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. | +| `NewsletterAdmin.tsx` | Three-column newsletter admin page for managing subscribers and sending newsletters. Left sidebar with navigation and stats, main area with searchable subscriber list, right sidebar with send newsletter panel and recent sends. Access at /newsletter-admin, configurable via siteConfig.newsletterAdmin. | ### Components (`src/components/`) @@ -67,6 +68,9 @@ A brief description of each file in the codebase. | `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state | | `RightSidebar.tsx` | Right sidebar component that displays CopyPageDropdown or AI chat on posts/pages at 1135px+ viewport width, controlled by siteConfig.rightSidebar.enabled and frontmatter rightSidebar/aiChat fields | | `AIChatView.tsx` | AI chat interface component (Agent) using Anthropic Claude API. Supports per-page chat history, page content context, markdown rendering, and copy functionality. Used in Write page (replaces textarea when enabled) and optionally in RightSidebar. Requires ANTHROPIC_API_KEY environment variable in Convex. System prompt configurable via CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT environment variables. Includes error handling for missing API keys. | +| `NewsletterSignup.tsx` | Newsletter signup form component for email-only subscriptions. Displays configurable title/description, validates email, and submits to Convex. Shows on home, blog page, and posts based on siteConfig.newsletter settings. Supports frontmatter override via newsletter: true/false. | +| `ContactForm.tsx` | Contact form component with name, email, and message fields. Displays when contactForm: true in frontmatter. Submits to Convex which sends email via AgentMail to configured recipient. | +| `SocialFooter.tsx` | Social footer component with social icons on left (GitHub, Twitter/X, LinkedIn, etc.) and copyright on right. Configurable via siteConfig.socialFooter. Shows below main footer on homepage, blog posts, and pages. Supports frontmatter override via showSocialFooter: true/false. | ### Context (`src/context/`) @@ -98,16 +102,19 @@ A brief description of each file in the codebase. | File | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats) with indexes for tag and AI queries | +| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, newsletterSubscribers, newsletterSentPosts, contactMessages) with indexes for tag and AI queries. Posts and pages include showSocialFooter field for frontmatter control. | | `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, and getRelatedPosts | | `pages.ts` | Queries and mutations for static pages | | `search.ts` | Full text search queries across posts and pages | | `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat | -| `crons.ts` | Cron job for stale session cleanup | +| `crons.ts` | Cron jobs for stale session cleanup, weekly newsletter digest (Sundays 9am UTC), and weekly stats summary (Mondays 9am UTC) | | `http.ts` | HTTP endpoints: sitemap, API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast) | | `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) | | `aiChats.ts` | Queries and mutations for AI chat history (per-session, per-context storage). Handles anonymous session IDs, per-page chat contexts, and message history management. Supports page content as context for AI responses. | | `aiChatActions.ts` | Anthropic Claude API integration action for AI chat responses. Requires ANTHROPIC_API_KEY environment variable in Convex. Uses claude-sonnet-3-5-20240620 model. System prompt configurable via environment variables (CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT). Includes error handling for missing API keys with user-friendly error messages. Supports page content context and chat history (last 20 messages). | +| `newsletter.ts` | Newsletter mutations and queries: subscribe, unsubscribe, getSubscriberCount, getActiveSubscribers, getAllSubscribers (admin), deleteSubscriber (admin), getNewsletterStats, getPostsForNewsletter, wasPostSent, recordPostSent. | +| `newsletterActions.ts` | Newsletter actions (Node.js runtime): sendPostNewsletter, sendWeeklyDigest, notifyNewSubscriber, sendWeeklyStatsSummary. Uses AgentMail SDK for email delivery. | +| `contact.ts` | Contact form mutations and actions: submitContact, sendContactEmail (AgentMail API), markEmailSent. | | `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) | | `tsconfig.json` | Convex TypeScript configuration | @@ -151,7 +158,11 @@ Markdown files with frontmatter for blog posts. Each file becomes a blog post. | `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) | | `showFooter` | Show footer on this post (optional, overrides siteConfig default) | | `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) | -| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) | +| `showSocialFooter` | Show social footer on this post (optional, overrides siteConfig default) | +| `aiChat` | Enable AI Agent chat in right sidebar (optional). Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `blogFeatured` | Show as featured on blog page (optional, first becomes hero, rest in 2-column row) | +| `newsletter` | Override newsletter signup display (optional, true/false) | +| `contactForm` | Enable contact form on this post (optional) | ## Static Pages (`content/pages/`) @@ -174,7 +185,10 @@ Markdown files for static pages like About, Projects, Contact, Changelog. | `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) | | `showFooter` | Show footer on this page (optional, overrides siteConfig default) | | `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) | -| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) | +| `showSocialFooter` | Show social footer on this page (optional, overrides siteConfig default) | +| `aiChat` | Enable AI Agent chat in right sidebar (optional). Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `newsletter` | Override newsletter signup display (optional, true/false) | +| `contactForm` | Enable contact form on this page (optional) | ## Scripts (`scripts/`) @@ -184,6 +198,8 @@ Markdown files for static pages like About, Projects, Contact, Changelog. | `sync-discovery-files.ts` | Updates AGENTS.md and llms.txt with current app data | | `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) | | `configure-fork.ts` | Automated fork configuration (reads fork-config.json) | +| `send-newsletter.ts` | CLI tool for sending newsletter posts (npm run newsletter:send ) | +| `send-newsletter-stats.ts` | CLI tool for sending weekly stats summary (npm run newsletter:send:stats) | ### Sync Commands diff --git a/fork-config.json.example b/fork-config.json.example index d97ba75..92a3ece 100644 --- a/fork-config.json.example +++ b/fork-config.json.example @@ -55,6 +55,32 @@ "type": "default", "slug": null, "originalHomeRoute": "/home" + }, + "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." + } + } } } diff --git a/package-lock.json b/package-lock.json index 8d779b1..9226af8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@mendable/firecrawl-js": "^1.21.1", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-icons": "^1.3.2", + "agentmail": "^0.1.15", "convex": "^1.17.4", "date-fns": "^3.3.1", "gray-matter": "^4.0.3", @@ -1785,6 +1786,17 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agentmail": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/agentmail/-/agentmail-0.1.15.tgz", + "integrity": "sha512-BXnTcAFbB30RzLxg+gPs2weHGI1e6pndHIieNSd1sXlPX0w52qiz2ZXQebadbBzKet+/Ix46U0WG8nXPZIymMQ==", + "dependencies": { + "ws": "^8.16.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -6970,7 +6982,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 46e90d4..48e8c26 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "sync:all:prod": "npm run sync:prod && npm run sync:discovery:prod", "import": "npx tsx scripts/import-url.ts", "configure": "npx tsx scripts/configure-fork.ts", + "newsletter:send": "npx tsx scripts/send-newsletter.ts", + "newsletter:send:stats": "npx tsx scripts/send-newsletter-stats.ts", "deploy": "npm run sync && npm run build", "deploy:prod": "npx convex deploy && npm run sync:prod" }, @@ -27,6 +29,7 @@ "@mendable/firecrawl-js": "^1.21.1", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-icons": "^1.3.2", + "agentmail": "^0.1.15", "convex": "^1.17.4", "date-fns": "^3.3.1", "gray-matter": "^4.0.3", diff --git a/prds/agentmail-contact-form-fix.md b/prds/agentmail-contact-form-fix.md new file mode 100644 index 0000000..895cba0 --- /dev/null +++ b/prds/agentmail-contact-form-fix.md @@ -0,0 +1,96 @@ +# AgentMail Contact Form Fix + +## Problem + +Contact form submissions were failing with 404 errors when trying to send emails via AgentMail. The error message was: + +``` +Failed to send contact email (404): {"message":"Not Found"} +``` + +## Root Cause + +The code was attempting to use a REST API endpoint that doesn't exist: + +``` +POST https://api.agentmail.to/v1/inboxes/{inbox_id}/messages +``` + +AgentMail doesn't expose a public REST API for sending emails. They require using their official SDK (`agentmail` npm package) instead. + +Additionally, Convex functions that use Node.js packages (like the AgentMail SDK) must run in the Node.js runtime, which requires the `"use node"` directive. However, mutations and queries must run in V8. This created a conflict when trying to use the SDK in the same file as mutations. + +## Solution + +### 1. Install Official SDK + +```bash +npm install agentmail +``` + +### 2. Split Actions into Separate Files + +Created separate files for Node.js actions: + +- `convex/contactActions.ts` - Contains `sendContactEmail` action with `"use node"` +- `convex/newsletterActions.ts` - Contains `sendPostNewsletter` action with `"use node"` + +Main files remain in V8 runtime: + +- `convex/contact.ts` - Contains mutations (`submitContact`, `markEmailSent`) +- `convex/newsletter.ts` - Contains mutations and queries + +### 3. Use SDK Instead of REST API + +Replaced fetch calls with the official SDK: + +```typescript +// Before (didn't work) +const response = await fetch(apiUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(emailPayload), +}); + +// After (works) +const client = new AgentMailClient({ apiKey }); +await client.inboxes.messages.send(inbox, { + to: recipientEmail, + subject: "...", + text: "...", + html: "...", +}); +``` + +## Files Modified + +- `convex/contact.ts` - Removed `"use node"`, kept mutations only +- `convex/contactActions.ts` - New file with `sendContactEmail` action using SDK +- `convex/newsletter.ts` - Removed `"use node"`, kept mutations/queries only +- `convex/newsletterActions.ts` - New file with `sendPostNewsletter` action using SDK +- `package.json` - Added `agentmail` dependency + +## Testing + +After the fix: + +1. Contact form submissions store messages in Convex +2. Emails send successfully via AgentMail SDK +3. No more 404 errors +4. Proper error handling and logging + +## Key Learnings + +- AgentMail requires their SDK, not direct REST API calls +- Convex actions using Node.js packages need `"use node"` directive +- Mutations/queries must run in V8, actions can run in Node.js +- Split files by runtime requirement to avoid conflicts + +## References + +- [AgentMail Quickstart](https://docs.agentmail.to/quickstart) +- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email) +- [Convex Actions Documentation](https://docs.convex.dev/functions/actions) diff --git a/prds/agentmail-newsletter-v1.md b/prds/agentmail-newsletter-v1.md new file mode 100644 index 0000000..f09d31a --- /dev/null +++ b/prds/agentmail-newsletter-v1.md @@ -0,0 +1,159 @@ +# AgentMail Newsletter Integration v1 + +## Overview + +Email-only newsletter system integrated with AgentMail. All features are optional and controlled via `siteConfig.ts` and frontmatter. + +## Implemented Features + +### Phase 1: Newsletter Signup + +| Feature | Status | Description | +|---------|--------|-------------| +| Site Config | Done | `NewsletterConfig` interface in `siteConfig.ts` | +| Schema | Done | `newsletterSubscribers` table with indexes | +| Subscribe Mutation | Done | Email validation, duplicate detection, re-subscribe support | +| Unsubscribe Mutation | Done | Token verification for security | +| Subscriber Queries | Done | `getSubscriberCount`, `getActiveSubscribers` | +| NewsletterSignup Component | Done | Email input form with status feedback | +| CSS Styling | Done | Responsive styles for all themes | +| Home Integration | Done | Configurable position (above-footer, below-intro) | +| Blog Page Integration | Done | Configurable position (above-footer, below-posts) | +| Post Integration | Done | Frontmatter override support | +| Unsubscribe Page | Done | `/unsubscribe` route with auto-processing | + +### Phase 2: Newsletter Sending + +| Feature | Status | Description | +|---------|--------|-------------| +| Sent Posts Schema | Done | `newsletterSentPosts` table to track sent newsletters | +| Send Action | Done | `sendPostNewsletter` internalAction using AgentMail API | +| CLI Script | Done | `npm run newsletter:send ` | + +## Files Created/Modified + +### New Files + +- `convex/newsletter.ts` - Subscribe, unsubscribe, and sending functions +- `src/components/NewsletterSignup.tsx` - React component +- `src/pages/Unsubscribe.tsx` - Unsubscribe page +- `scripts/send-newsletter.ts` - CLI tool for sending newsletters +- `prds/agentmail-newsletter-v1.md` - This file + +### Modified Files + +- `src/config/siteConfig.ts` - Added `NewsletterConfig` interface +- `convex/schema.ts` - Added `newsletterSubscribers` and `newsletterSentPosts` tables +- `convex/posts.ts` - Added `getPostBySlugInternal` query +- `src/styles/global.css` - Added newsletter component styles +- `src/pages/Home.tsx` - Integrated `NewsletterSignup` +- `src/pages/Blog.tsx` - Integrated `NewsletterSignup` +- `src/pages/Post.tsx` - Integrated `NewsletterSignup` with frontmatter support +- `src/App.tsx` - Added `/unsubscribe` route +- `package.json` - Added `newsletter:send` script +- `fork-config.json.example` - Added newsletter configuration +- `FORK_CONFIG.md` - Added newsletter documentation + +## Configuration + +### Environment Variables (Convex Dashboard) + +| Variable | Description | +|----------|-------------| +| `AGENTMAIL_API_KEY` | Your AgentMail API key | +| `AGENTMAIL_INBOX` | Your inbox address (e.g., `newsletter@mail.agentmail.to`) | + +### Site Config Example + +```typescript +newsletter: { + enabled: true, + agentmail: { + inbox: "newsletter@mail.agentmail.to", + }, + signup: { + home: { + enabled: true, + position: "above-footer", + title: "Stay Updated", + description: "Get new posts delivered to your inbox.", + }, + blogPage: { + enabled: true, + position: "above-footer", + title: "Subscribe", + description: "Get notified when new posts are published.", + }, + posts: { + enabled: true, + position: "below-content", + title: "Enjoyed this post?", + description: "Subscribe for more updates.", + }, + }, +}, +``` + +### Frontmatter Override + +```yaml +--- +title: My Post +newsletter: false # Hide newsletter on this post +--- +``` + +## Usage + +### Collect Subscribers + +1. Enable newsletter in `siteConfig.ts` +2. Set environment variables in Convex dashboard +3. Subscribers can sign up from homepage, blog page, or individual posts + +### Send Newsletter + +```bash +# Check post exists and show send command +npm run newsletter:send + +# Or use Convex CLI directly +npx convex run newsletter:sendPostNewsletter '{"postSlug":"slug","siteUrl":"https://site.com","siteName":"Name"}' +``` + +### View Subscriber Count + +Subscriber count is available via the `newsletter.getSubscriberCount` query. + +## Database Schema + +### newsletterSubscribers + +| Field | Type | Description | +|-------|------|-------------| +| email | string | Subscriber email (lowercase, trimmed) | +| subscribed | boolean | Current subscription status | +| subscribedAt | number | Timestamp when subscribed | +| unsubscribedAt | number? | Timestamp when unsubscribed | +| source | string | Signup location ("home", "blog-page", "post:slug") | +| unsubscribeToken | string | Secure token for unsubscribe links | + +Indexes: `by_email`, `by_subscribed` + +### newsletterSentPosts + +| Field | Type | Description | +|-------|------|-------------| +| postSlug | string | Slug of the sent post | +| sentAt | number | Timestamp when sent | +| sentCount | number | Number of subscribers sent to | + +Index: `by_postSlug` + +## Future Enhancements (Not Implemented) + +- Double opt-in confirmation emails +- Contact form integration +- Weekly digest automation +- Subscriber admin UI in dashboard +- Email templates customization diff --git a/public/images/logos/mcp.svg b/public/images/logos/mcp.svg new file mode 100644 index 0000000..5cd83a8 --- /dev/null +++ b/public/images/logos/mcp.svg @@ -0,0 +1 @@ +ModelContextProtocol \ No newline at end of file diff --git a/public/raw/changelog.md b/public/raw/changelog.md index f5a6253..5d988c4 100644 --- a/public/raw/changelog.md +++ b/public/raw/changelog.md @@ -8,6 +8,161 @@ Date: 2025-12-27 All notable changes to this project. ![](https://img.shields.io/badge/License-MIT-yellow.svg) +## v1.38.0 + +Released December 27, 2025 + +**Newsletter CLI improvements** + +- `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly + - Sends emails in the background instead of printing instructions + - Provides clear success/error feedback + - Shows helpful messages about checking Newsletter Admin for results +- New `newsletter:send:stats` command + - Sends weekly stats summary to your inbox on demand + - Uses `scheduleSendStatsSummary` mutation + - Email sent to AGENTMAIL_INBOX or AGENTMAIL_CONTACT_EMAIL +- New mutation `scheduleSendStatsSummary` in `convex/newsletter.ts` + - Allows CLI to trigger stats summary sending + - Schedules `sendWeeklyStatsSummary` internal action + +**Documentation** + +- Blog post: "How to use AgentMail with Markdown Sync" + - Complete setup guide for AgentMail integration + - Environment variables configuration + - Newsletter and contact form features + - CLI commands documentation + - Troubleshooting section +- Updated docs.md with new CLI commands +- Updated files.md with new script reference +- Verified all AgentMail features use environment variables (no hardcoded emails) + +Updated files: `scripts/send-newsletter.ts`, `scripts/send-newsletter-stats.ts`, `convex/newsletter.ts`, `package.json`, `content/blog/how-to-use-agentmail.md`, `content/pages/docs.md`, `files.md`, `changelog.md`, `content/pages/changelog-page.md`, `TASK.md` + +## v1.37.0 + +Released December 27, 2025 + +**Newsletter Admin UI** + +- Newsletter Admin UI at `/newsletter-admin` + - Three-column layout similar to Write page + - View all subscribers with search and filter (all/active/unsubscribed) + - Stats showing active, total, and sent newsletter counts + - Delete subscribers directly from admin + - Send newsletter panel with two modes: + - Send Post: Select a blog post to send as newsletter + - Write Email: Compose custom email with markdown support + - Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists) + - Copy icon on success messages to copy CLI commands + - Theme-aware success/error styling (no hardcoded green) + - Recent newsletters list showing sent history + - Configurable via `siteConfig.newsletterAdmin` + +**Weekly Digest automation** + +- Cron job runs every Sunday at 9:00 AM UTC +- Automatically sends all posts published in the last 7 days +- Uses AgentMail SDK for email delivery +- Configurable via `siteConfig.weeklyDigest` + +**Developer Notifications** + +- New subscriber alerts sent via email when someone subscribes +- Weekly stats summary sent every Monday at 9:00 AM UTC +- Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient +- Configurable via `siteConfig.newsletterNotifications` + +**Admin queries and mutations** + +- `getAllSubscribers`: Paginated subscriber list with search/filter +- `deleteSubscriber`: Remove subscriber from database +- `getNewsletterStats`: Stats for admin dashboard +- `getPostsForNewsletter`: List of posts with sent status + +Updated files: `convex/newsletter.ts`, `convex/newsletterActions.ts`, `convex/posts.ts`, `convex/crons.ts`, `src/config/siteConfig.ts`, `src/App.tsx`, `src/styles/global.css`, `src/pages/NewsletterAdmin.tsx` + +## v1.36.0 + +Released December 27, 2025 + +**Social footer component** + +- Social footer component with customizable social links and copyright + - Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more) + - Shows copyright symbol, site name, and auto-updating year on the right + - Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts` + - Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website + - Uses Phosphor icons for consistent styling + - Appears below the main footer on homepage, blog posts, and pages + - Can work independently of the main footer when set via frontmatter + +**Frontmatter control for social footer** + +- `showSocialFooter` field for posts and pages to override siteConfig defaults +- Set `showSocialFooter: false` to hide on specific posts/pages +- Works like existing `showFooter` field pattern + +**Social footer configuration options** + +- `enabled`: Global toggle for social footer +- `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility +- `socialLinks`: Array of social link objects with platform and URL +- `copyright.siteName`: Site/company name for copyright display +- `copyright.showYear`: Toggle for auto-updating year + +Updated files: `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/pages/Home.tsx`, `src/pages/Post.tsx`, `src/pages/Blog.tsx`, `src/styles/global.css`, `src/components/SocialFooter.tsx` + +## v1.35.0 + +Released December 26, 2025 + +**Image support at top of posts and pages** + +- `showImageAtTop` frontmatter field for posts and pages + - Set `showImageAtTop: true` to display the `image` field at the top of the post/page above the header + - Image displays full-width with rounded corners above the post header + - Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails + - Works for both blog posts and static pages + - Image appears above the post header when enabled + +Updated files: `convex/schema.ts`, `scripts/sync-posts.ts`, `convex/posts.ts`, `convex/pages.ts`, `src/pages/Post.tsx`, `src/pages/Write.tsx`, `src/styles/global.css` + +Documentation updated: `content/pages/docs.md`, `content/blog/how-to-publish.md`, `content/blog/using-images-in-posts.md`, `files.md` + +## v1.34.0 + +Released December 26, 2025 + +**Blog page featured layout with hero post** + +- `blogFeatured` frontmatter field for posts to mark as featured on blog page + - First `blogFeatured` post displays as hero card with landscape image, tags, date, title, excerpt, author info, and read more link + - Remaining `blogFeatured` posts display in 2-column featured row with excerpts + - Regular (non-featured) posts display in 3-column grid without excerpts + - New `BlogHeroCard` component (`src/components/BlogHeroCard.tsx`) for hero display + - New `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true` sorted by date + - `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop + - Card images use 16:10 landscape aspect ratio + - Footer support on blog page via `siteConfig.footer.showOnBlogPage` + +Updated files: `convex/schema.ts`, `convex/posts.ts`, `scripts/sync-posts.ts`, `src/pages/Blog.tsx`, `src/components/PostList.tsx`, `src/styles/global.css` + +## v1.33.1 + +Released December 26, 2025 + +**Article centering in sidebar layouts** + +- Article content now centers in the middle column when sidebars are present + - Left sidebar stays flush left, right sidebar stays flush right + - Article uses `margin-left: auto; margin-right: auto` within its `1fr` grid column + - Works with both two-column (left sidebar only) and three-column (both sidebars) layouts + - Consistent `max-width: 800px` for article content across all sidebar configurations + +Updated files: `src/styles/global.css` + ## v1.33.0 Released December 26, 2025 diff --git a/public/raw/contact.md b/public/raw/contact.md index 4ed309b..36214de 100644 --- a/public/raw/contact.md +++ b/public/raw/contact.md @@ -7,37 +7,10 @@ Date: 2025-12-27 You found the contact page. Nice + + ## The technical way This site runs on Convex, which means every page view is a live subscription to the database. You are not reading cached HTML. You are reading data that synced moments ago. -If you want to reach out, here is an idea: fork this repo, add a contact form, wire it to a Convex mutation, and deploy. Your message will hit the database in under 100ms. No email server required. - -```typescript -// A contact form mutation looks like this -export const submitContact = mutation({ - args: { - name: v.string(), - email: v.string(), - message: v.string(), - }, - handler: async (ctx, args) => { - await ctx.db.insert("messages", { - ...args, - createdAt: Date.now(), - }); - }, -}); -``` - -## The human way - -Open an issue on GitHub. Or find the author on X. Or send a carrier pigeon. Convex does not support those yet, but the team is probably working on it. - -## Why Convex - -Traditional backends make you write API routes, manage connections, handle caching, and pray nothing breaks at 3am. Convex handles all of that. You write functions. They run in the cloud. Data syncs to clients. Done. - -The contact form example above is the entire backend. No Express. No database drivers. No WebSocket setup. Just a function that inserts a row. - -That is why this site uses Convex. \ No newline at end of file +If you want to reach out, here is an idea: fork this repo, add a contact form, wire it to a Convex mutation, and deploy. Your message will hit the database in under 100ms. No email server required. \ No newline at end of file diff --git a/public/raw/docs.md b/public/raw/docs.md index e2ebabb..f43a629 100644 --- a/public/raw/docs.md +++ b/public/raw/docs.md @@ -98,23 +98,31 @@ image: "/images/og-image.png" Content here... ``` -| Field | Required | Description | -| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | -| `title` | Yes | Post title | -| `description` | Yes | SEO description | -| `date` | Yes | YYYY-MM-DD format | -| `slug` | Yes | URL path (unique) | -| `published` | Yes | `true` to show | -| `tags` | Yes | Array of strings | -| `readTime` | No | Display time estimate | -| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax | -| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) | -| `excerpt` | No | Short text for card view | -| `featured` | No | `true` to show in featured section | -| `featuredOrder` | No | Order in featured (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| Field | Required | Description | +| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `title` | Yes | Post title | +| `description` | Yes | SEO description | +| `date` | Yes | YYYY-MM-DD format | +| `slug` | Yes | URL path (unique) | +| `published` | Yes | `true` to show | +| `tags` | Yes | Array of strings | +| `readTime` | No | Display time estimate | +| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax | +| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) | +| `excerpt` | No | Short text for card view | +| `featured` | No | `true` to show in featured section | +| `featuredOrder` | No | Order in featured (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `showFooter` | No | Show footer on this post (overrides siteConfig default) | +| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) | +| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) | +| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) | +| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | +| `contactForm` | No | Enable contact form on this post | ### Static pages @@ -131,22 +139,28 @@ order: 1 Content here... ``` -| Field | Required | Description | -| --------------- | -------- | ----------------------------------------------------------------------------- | -| `title` | Yes | Nav link text | -| `slug` | Yes | URL path | -| `published` | Yes | `true` to show | -| `order` | No | Nav order (lower = first) | -| `showInNav` | No | Show in navigation menu (default: `true`) | -| `excerpt` | No | Short text for card view | -| `image` | No | Thumbnail for featured card view | -| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) | -| `featured` | No | `true` to show in featured section | -| `featuredOrder` | No | Order in featured (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | -| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| Field | Required | Description | +| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `title` | Yes | Nav link text | +| `slug` | Yes | URL path | +| `published` | Yes | `true` to show | +| `order` | No | Nav order (lower = first) | +| `showInNav` | No | Show in navigation menu (default: `true`) | +| `excerpt` | No | Short text for card view | +| `image` | No | Thumbnail for featured card view | +| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) | +| `featured` | No | `true` to show in featured section | +| `featuredOrder` | No | Order in featured (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `showFooter` | No | Show footer on this page (overrides siteConfig default) | +| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) | +| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) | +| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | +| `contactForm` | No | Enable contact form on this page | **Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. @@ -799,11 +813,86 @@ The `/stats` page displays real-time analytics: All stats update automatically via Convex subscriptions. +## Newsletter Admin + +The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subscribers and sending newsletters. + +**Features:** + +- View and search all subscribers (search bar in header) +- Filter by status (all, active, unsubscribed) +- Delete subscribers +- Send blog posts as newsletters +- Write and send custom emails with markdown support +- View recent newsletter sends (last 10, includes both posts and custom emails) +- Email statistics dashboard with: + - Total emails sent + - Newsletters sent count + - Active subscribers + - Retention rate + - Detailed summary table + +**Configuration:** + +Enable in `src/config/siteConfig.ts`: + +```typescript +newsletterAdmin: { + enabled: true, // Enable /newsletter-admin route + showInNav: false, // Hide from navigation (access via direct URL) +}, +``` + +**Environment Variables (Convex):** + +| Variable | Description | +| ------------------------- | --------------------------------------------------- | +| `AGENTMAIL_API_KEY` | Your AgentMail API key | +| `AGENTMAIL_INBOX` | Your AgentMail inbox (e.g., `inbox@agentmail.to`) | +| `AGENTMAIL_CONTACT_EMAIL` | Optional contact form recipient (defaults to inbox) | + +**Note:** If environment variables are not configured, users will see the error message: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." when attempting to send newsletters or use contact forms. + +**Sending Newsletters:** + +The admin UI supports two sending modes: + +1. **Send Post**: Select a published blog post to send as a newsletter +2. **Write Email**: Compose a custom email with markdown formatting + +Custom emails support markdown syntax: + +- `# Heading` for headers +- `**bold**` and `*italic*` for emphasis +- `[link text](url)` for links +- `- item` for bullet lists + +**CLI Commands:** + +You can send newsletters via command line: + +```bash +# Send a blog post to all subscribers +npm run newsletter:send + +# Send weekly stats summary to your inbox +npm run newsletter:send:stats +``` + +Example: + +```bash +npm run newsletter:send setup-guide +``` + +The `newsletter:send` command calls the `scheduleSendPostNewsletter` mutation directly and sends emails in the background. Check the Newsletter Admin page or recent sends to see results. + ## API endpoints | Endpoint | Description | | ------------------------------ | --------------------------- | | `/stats` | Real-time analytics | +| `/newsletter-admin` | Newsletter management UI | | `/rss.xml` | RSS feed (descriptions) | | `/rss-full.xml` | RSS feed (full content) | | `/sitemap.xml` | XML sitemap | diff --git a/public/raw/how-to-use-agentmail.md b/public/raw/how-to-use-agentmail.md new file mode 100644 index 0000000..9877a58 --- /dev/null +++ b/public/raw/how-to-use-agentmail.md @@ -0,0 +1,242 @@ +# How to use AgentMail with Markdown Sync + +> Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog + +--- +Type: post +Date: 2025-12-27 +Reading time: 5 min read +Tags: agentmail, newsletter, email, setup +--- + +AgentMail provides email infrastructure for your markdown blog, enabling newsletter subscriptions, contact forms, and automated email notifications. This guide covers setup, configuration, and usage. + +## What is AgentMail + +AgentMail is an email service designed for AI agents and developers. It handles email sending and receiving without OAuth or MFA requirements, making it ideal for automated workflows. + +For this markdown blog framework, AgentMail powers: + +- Newsletter subscriptions and sending +- Contact forms on posts and pages +- Developer notifications for new subscribers +- Weekly digest emails +- Weekly stats summaries + +## Setup + +### 1. Create an AgentMail account + +Sign up at [agentmail.to](https://agentmail.to) and create an inbox. Your inbox address will look like `yourname@agentmail.to`. + +### 2. Get your API key + +In the AgentMail dashboard, navigate to API settings and copy your API key. You'll need this for Convex environment variables. + +### 3. Configure Convex environment variables + +In your Convex dashboard, go to Settings > Environment Variables and add: + +| Variable | Description | Required | +|----------|-------------|----------| +| `AGENTMAIL_API_KEY` | Your AgentMail API key | Yes | +| `AGENTMAIL_INBOX` | Your inbox address (e.g., `markdown@agentmail.to`) | Yes | +| `AGENTMAIL_CONTACT_EMAIL` | Contact form recipient (defaults to inbox if not set) | No | + +**Important:** Never hardcode email addresses in your code. Always use environment variables. + +### 4. Enable features in siteConfig + +Edit `src/config/siteConfig.ts` to enable newsletter and contact form features: + +```typescript +newsletter: { + enabled: true, + showOnHomepage: true, + showOnBlogPage: true, + showOnPosts: true, + title: "Subscribe to the newsletter", + description: "Get updates delivered to your inbox", +}, + +contactForm: { + enabled: true, + title: "Get in touch", + description: "Send us a message", +}, +``` + +## Newsletter features + +### Subscriber management + +The Newsletter Admin page at `/newsletter-admin` provides: + +- View all subscribers with search and filters +- Delete subscribers +- Send blog posts as newsletters +- Write and send custom emails with markdown support +- View email statistics dashboard +- Track recent sends (last 10) + +### Sending newsletters + +**Via CLI:** + +```bash +# Send a specific post to all subscribers +npm run newsletter:send setup-guide + +# Send weekly stats summary to your inbox +npm run newsletter:send:stats +``` + +**Via Admin UI:** + +1. Navigate to `/newsletter-admin` +2. Select "Send Post" or "Write Email" from the sidebar +3. Choose a post or compose a custom email +4. Click "Send Newsletter" + +### Weekly digest + +Automated weekly digest emails are sent every Sunday at 9:00 AM UTC. They include all posts published in the last 7 days. + +Configure in `siteConfig.ts`: + +```typescript +weeklyDigest: { + enabled: true, +}, +``` + +### Developer notifications + +Receive email notifications when: + +- A new subscriber signs up +- Weekly stats summary (every Monday at 9:00 AM UTC) + +Configure in `siteConfig.ts`: + +```typescript +newsletterNotifications: { + enabled: true, +}, +``` + +Notifications are sent to `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` if contact email is not set. + +## Contact forms + +### Enable on posts and pages + +Add `contactForm: true` to any post or page frontmatter: + +```markdown +--- +title: "Contact Us" +slug: "contact" +published: true +contactForm: true +--- + +Your page content here... +``` + +The contact form includes: + +- Name field +- Email field +- Message field + +Submissions are stored in Convex and sent via AgentMail to your configured recipient. + +### Frontmatter options + +| Field | Type | Description | +|-------|------|-------------| +| `contactForm` | boolean | Enable contact form on this post/page | + +## Frontmatter options + +### Newsletter signup + +Control newsletter signup display per post/page: + +```markdown +--- +title: "My Post" +newsletter: true # Show signup (default: follows siteConfig) +--- +``` + +Or hide it: + +```markdown +--- +title: "My Post" +newsletter: false # Hide signup even if enabled globally +--- +``` + +## Environment variables + +All AgentMail features require these Convex environment variables: + +**Required:** + +- `AGENTMAIL_API_KEY` - Your AgentMail API key +- `AGENTMAIL_INBOX` - Your inbox address + +**Optional:** + +- `AGENTMAIL_CONTACT_EMAIL` - Contact form recipient (defaults to inbox) + +**Note:** If environment variables are not configured, users will see: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." + +## CLI commands + +| Command | Description | +|---------|-------------| +| `npm run newsletter:send ` | Send a blog post to all subscribers | +| `npm run newsletter:send:stats` | Send weekly stats summary to your inbox | + +## Troubleshooting + +**Emails not sending:** + +1. Verify `AGENTMAIL_API_KEY` and `AGENTMAIL_INBOX` are set in Convex dashboard +2. Check Convex function logs for error messages +3. Ensure your inbox is active in AgentMail dashboard + +**Contact form not appearing:** + +1. Verify `contactForm: true` is in frontmatter +2. Check `siteConfig.contactForm.enabled` is `true` +3. Run `npm run sync` to sync frontmatter changes + +**Newsletter Admin not accessible:** + +1. Verify `siteConfig.newsletterAdmin.enabled` is `true` +2. Navigate to `/newsletter-admin` directly (hidden from nav by default) + +## Resources + +- [AgentMail Documentation](https://docs.agentmail.to) +- [AgentMail Quickstart](https://docs.agentmail.to/quickstart) +- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email) +- [AgentMail Inboxes](https://docs.agentmail.to/inboxes) + +## Summary + +AgentMail integration provides: + +- Newsletter subscriptions and sending +- Contact forms on any post or page +- Automated weekly digests +- Developer notifications +- Admin UI for subscriber management +- CLI tools for sending newsletters and stats + +All features use Convex environment variables for configuration. No hardcoded emails in your codebase. \ No newline at end of file diff --git a/public/raw/how-to-use-firecrawl.md b/public/raw/how-to-use-firecrawl.md new file mode 100644 index 0000000..6bc3d0d --- /dev/null +++ b/public/raw/how-to-use-firecrawl.md @@ -0,0 +1,66 @@ +# How to use Firecrawl + +> Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat. + +--- +Type: post +Date: 2025-01-20 +Reading time: 2 min read +Tags: tutorial, firecrawl, import +--- + +# How to use Firecrawl + +You found an article you want to republish or reference. Copying content manually takes time. Firecrawl scrapes web pages and converts them to markdown automatically. + +## What it is + +Firecrawl is a web scraping service that turns any URL into clean markdown. This app uses it in two places: the import script for creating draft posts, and the AI chat feature for fetching page content. + +## Who it's for + +Developers who want to import external articles without manual copying. If you republish content or need to reference external sources, Firecrawl saves time. + +## The problem it solves + +Manually copying content from websites is slow. You copy text, fix formatting, add frontmatter, and handle images. Firecrawl does this automatically. + +## How it works + +The import script scrapes a URL, extracts the title and description, converts HTML to markdown, and creates a draft post in `content/blog/`. The AI chat feature uses Firecrawl to fetch page content when you share URLs in conversations. + +## How to try it + +**Step 1: Get your API key** + +Visit [firecrawl.dev](https://firecrawl.dev) and sign up. Copy your API key. It starts with `fc-`. + +**Step 2: Set up local imports** + +Add the key to `.env.local` in your project root: + +``` +FIRECRAWL_API_KEY=fc-your-api-key-here +``` + +Now you can import articles: + +```bash +npm run import https://example.com/article +``` + +This creates a draft post in `content/blog/`. Review it, set `published: true`, then run `npm run sync`. + +**Step 3: Enable AI chat scraping** + +If you use the AI chat feature, set the same key in your Convex Dashboard: + +1. Go to [dashboard.convex.dev](https://dashboard.convex.dev) +2. Select your project +3. Open Settings > Environment Variables +4. Add `FIRECRAWL_API_KEY` with your key value +5. Deploy: `npx convex deploy` + +The AI chat can now fetch content from URLs you share. + +That's it. One API key, two places to set it, and you're done. \ No newline at end of file diff --git a/public/raw/index.md b/public/raw/index.md index d5bb963..e1df1cb 100644 --- a/public/raw/index.md +++ b/public/raw/index.md @@ -2,8 +2,10 @@ This is the homepage index of all published content. -## Blog Posts (12) +## Blog Posts (14) +- **[How to use AgentMail with Markdown Sync](/raw/how-to-use-agentmail.md)** - Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog + - Date: 2025-12-27 | Reading time: 5 min read | Tags: agentmail, newsletter, email, setup - **[Happy holidays and thank you](/raw/happy-holidays-2025.md)** - A quick note of thanks for stars, forks, and feedback. More AI-first publishing features coming in 2026. - Date: 2025-12-25 | Reading time: 2 min read | Tags: updates, community, ai - **[Netlify edge functions blocking AI crawlers from static files](/raw/netlify-edge-excludedpath-ai-crawlers.md)** - Why excludedPath in netlify.toml isn't preventing edge functions from intercepting /raw/* requests, and how ChatGPT and Perplexity get blocked while Claude works. @@ -26,19 +28,22 @@ This is the homepage index of all published content. - Date: 2025-12-14 | Reading time: 8 min read | Tags: convex, netlify, tutorial, deployment - **[Using Images in Blog Posts](/raw/using-images-in-posts.md)** - Learn how to add header images, inline images, and Open Graph images to your markdown posts. - Date: 2025-12-14 | Reading time: 4 min read | Tags: images, tutorial, markdown, open-graph +- **[How to use Firecrawl](/raw/how-to-use-firecrawl.md)** - Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat. + - Date: 2025-01-20 | Reading time: 2 min read | Tags: tutorial, firecrawl, import - **[Git commit message best practices](/raw/git-commit-message-best-practices.md)** - A guide to writing clear, consistent commit messages that help your team understand changes and generate better changelogs. - Date: 2025-01-17 | Reading time: 5 min read | Tags: git, development, best-practices, workflow -## Pages (5) +## Pages (6) - **[Docs](/raw/docs.md)** - **[About](/raw/about.md)** - An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.. - **[Projects](/raw/projects.md)** - **[Contact](/raw/contact.md)** - **[Changelog](/raw/changelog.md)** +- **[Newsletter](/raw/newsletter.md)** --- -**Total Content:** 12 posts, 5 pages +**Total Content:** 14 posts, 6 pages All content is available as raw markdown files at `/raw/{slug}.md` diff --git a/public/raw/newsletter.md b/public/raw/newsletter.md new file mode 100644 index 0000000..c461537 --- /dev/null +++ b/public/raw/newsletter.md @@ -0,0 +1,28 @@ +# Newsletter + +--- +Type: page +Date: 2025-12-27 +--- + +# Newsletter + +Stay updated with the latest posts and updates from the markdown sync framework. + +## What you will get + +When you subscribe, you will receive: + +- Notifications when new blog posts are published +- Updates about new features and improvements +- Tips and tricks for getting the most out of markdown sync + +## Subscribe + +Use the form below to subscribe to our newsletter. We respect your privacy and you can unsubscribe at any time. + +## Privacy + +We only use your email address to send you newsletter updates. We never share your email with third parties or use it for any other purpose. + +To unsubscribe, click the unsubscribe link at the bottom of any newsletter email. \ No newline at end of file diff --git a/public/raw/setup-guide.md b/public/raw/setup-guide.md index 0ed1f51..0261676 100644 --- a/public/raw/setup-guide.md +++ b/public/raw/setup-guide.md @@ -56,6 +56,7 @@ This guide walks you through forking [this markdown framework](https://github.co - [Visitor Map](#visitor-map) - [Logo Gallery](#logo-gallery) - [Blog page](#blog-page) + - [Hardcoded Navigation Items](#hardcoded-navigation-items) - [Scroll-to-top button](#scroll-to-top-button) - [Change the Default Theme](#change-the-default-theme) - [Change the Font](#change-the-font) @@ -77,6 +78,7 @@ This guide walks you through forking [this markdown framework](https://github.co - [Build failures on Netlify](#build-failures-on-netlify) - [Project Structure](#project-structure) - [Write Page](#write-page) + - [AI Agent chat](#ai-agent-chat) - [Next Steps](#next-steps) ## Prerequisites @@ -181,6 +183,7 @@ export default defineSchema({ Blog posts live in `content/blog/` as markdown files. Sync them to Convex: **Development:** + ```bash npm run sync # Sync markdown content npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt) @@ -188,6 +191,7 @@ npm run sync:all # Sync content + discovery files together ``` **Production:** + ```bash npm run sync:prod # Sync markdown content npm run sync:discovery:prod # Update discovery files @@ -324,21 +328,21 @@ Your markdown content here... ### Frontmatter Fields -| Field | Required | Description | -| --------------- | -------- | ----------------------------------------- | -| `title` | Yes | Post title | -| `description` | Yes | Short description for SEO | -| `date` | Yes | Publication date (YYYY-MM-DD) | -| `slug` | Yes | URL path (must be unique) | -| `published` | Yes | Set to `true` to publish | -| `tags` | Yes | Array of topic tags | -| `readTime` | No | Estimated reading time | -| `image` | No | Header/Open Graph image URL | -| `excerpt` | No | Short excerpt for card view | -| `featured` | No | Set `true` to show in featured section | -| `featuredOrder` | No | Order in featured section (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | +| Field | Required | Description | +| --------------- | -------- | ----------------------------------------------------------------------------- | +| `title` | Yes | Post title | +| `description` | Yes | Short description for SEO | +| `date` | Yes | Publication date (YYYY-MM-DD) | +| `slug` | Yes | URL path (must be unique) | +| `published` | Yes | Set to `true` to publish | +| `tags` | Yes | Array of topic tags | +| `readTime` | No | Estimated reading time | +| `image` | No | Header/Open Graph image URL | +| `excerpt` | No | Short excerpt for card view | +| `featured` | No | Set `true` to show in featured section | +| `featuredOrder` | No | Order in featured section (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | | `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | ### How Frontmatter Works @@ -403,6 +407,7 @@ The `npm run sync` command only syncs markdown text content. Images are deployed After adding or editing posts, sync to Convex. **Development sync:** + ```bash npm run sync # Sync markdown content npm run sync:discovery # Update discovery files @@ -420,6 +425,7 @@ VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud Get your production URL from the [Convex Dashboard](https://dashboard.convex.dev) by selecting your project and switching to the Production deployment. Then sync: + ```bash npm run sync:prod # Sync markdown content npm run sync:discovery:prod # Update discovery files @@ -437,17 +443,17 @@ Both files are gitignored. Each developer creates their own local environment fi ### When to Sync vs Deploy -| What you're changing | Command | Timing | -| -------------------------------- | -------------------------- | -------------------- | -| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) | -| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) | -| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) | +| What you're changing | Command | Timing | +| -------------------------------- | -------------------------- | ----------------------- | +| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) | +| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) | +| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) | | Site config changes | `npm run sync:discovery` | Updates discovery files | -| Import external URL | `npm run import` then sync | Instant (no rebuild) | -| Images in `public/images/` | Git commit + push | Requires rebuild | -| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild | -| Logo gallery config | Redeploy | Requires rebuild | -| React components/styles | Redeploy | Requires rebuild | +| Import external URL | `npm run import` then sync | Instant (no rebuild) | +| Images in `public/images/` | Git commit + push | Requires rebuild | +| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild | +| Logo gallery config | Redeploy | Requires rebuild | +| React components/styles | Redeploy | Requires rebuild | **Markdown content** syncs instantly via Convex. **Images and source code** require pushing to GitHub for Netlify to rebuild. @@ -965,15 +971,12 @@ body { serif; /* Monospace */ - font-family: - "IBM Plex Mono", - "Liberation Mono", - ui-monospace, - monospace; + font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace; } ``` Available font options: + - `serif`: New York serif font (default) - `sans`: System sans-serif fonts - `monospace`: IBM Plex Mono monospace font @@ -1097,6 +1100,49 @@ How it works: - A cron job cleans up stale sessions every 5 minutes - No personal data is stored (only anonymous UUIDs) +## Newsletter Admin + +A newsletter management interface is available at `/newsletter-admin`. Use it to view subscribers, send newsletters, and compose custom emails. + +**Features:** + +- View and search all subscribers with filtering options (search bar in header) +- Delete subscribers from the admin UI +- Send published blog posts as newsletters +- Write custom emails using markdown formatting +- View recent newsletter sends (last 10, tracks both posts and custom emails) +- Email statistics dashboard with comprehensive metrics + +**Setup:** + +1. Enable in `src/config/siteConfig.ts`: + +```typescript +newsletterAdmin: { + enabled: true, + showInNav: false, // Keep hidden, access via direct URL +}, +``` + +2. Set environment variables in Convex Dashboard: + +| Variable | Description | +| ------------------------- | ------------------------------------ | +| `AGENTMAIL_API_KEY` | Your AgentMail API key | +| `AGENTMAIL_INBOX` | Your AgentMail inbox address | +| `AGENTMAIL_CONTACT_EMAIL` | Optional recipient for contact forms | + +**Important:** If environment variables are not configured, users will see an error message when attempting to use newsletter or contact form features: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." + +**Sending newsletters:** + +Two modes are available: + +1. **Send Post**: Select a blog post to send to all active subscribers +2. **Write Email**: Compose custom content with markdown support + +The admin UI shows send results and provides CLI commands as alternatives. + ## Mobile Navigation On mobile and tablet screens (under 768px), a hamburger menu provides navigation. The menu slides out from the left with keyboard navigation (Escape to close) and a focus trap for accessibility. It auto-closes when you navigate to a new route. @@ -1105,23 +1151,23 @@ On mobile and tablet screens (under 768px), a hamburger menu provides navigation Each post and page includes a share dropdown with options for AI tools: -| Option | Description | -| -------------------- | ------------------------------------------------- | -| Copy page | Copies formatted markdown to clipboard | -| Open in ChatGPT | Opens ChatGPT with raw markdown URL | -| Open in Claude | Opens Claude with raw markdown URL | -| Open in Perplexity | Opens Perplexity with raw markdown URL | -| View as Markdown | Opens raw `.md` file in new tab | -| Download as SKILL.md | Downloads skill file for AI agent training | +| Option | Description | +| -------------------- | ------------------------------------------ | +| Copy page | Copies formatted markdown to clipboard | +| Open in ChatGPT | Opens ChatGPT with raw markdown URL | +| Open in Claude | Opens Claude with raw markdown URL | +| Open in Perplexity | Opens Perplexity with raw markdown URL | +| View as Markdown | Opens raw `.md` file in new tab | +| Download as SKILL.md | Downloads skill file for AI agent training | **Git push required for AI links:** The "Open in ChatGPT," "Open in Claude," and "Open in Perplexity" options use GitHub raw URLs to fetch content. For these to work, your content must be pushed to GitHub with `git push`. The `npm run sync` command syncs content to Convex for your live site, but AI services fetch directly from GitHub. -| What you want | Command needed | -| ------------------------------------ | ------------------------------ | -| Content visible on your site | `npm run sync` or `sync:prod` | +| What you want | Command needed | +| ------------------------------------ | ------------------------------------------------- | +| Content visible on your site | `npm run sync` or `sync:prod` | | Discovery files updated | `npm run sync:discovery` or `sync:discovery:prod` | -| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub | -| Both content and discovery | `npm run sync:all` or `sync:all:prod` | +| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub | +| Both content and discovery | `npm run sync:all` or `sync:all:prod` | **Download as SKILL.md** formats the content as an Anthropic Agent Skills file with metadata, triggers, and instructions sections. @@ -1319,7 +1365,7 @@ Enable Agent in the right sidebar on individual posts or pages using the `aiChat --- title: "My Post" rightSidebar: true -aiChat: true # Enable Agent in right sidebar +aiChat: true # Enable Agent in right sidebar --- ``` diff --git a/scripts/send-newsletter-stats.ts b/scripts/send-newsletter-stats.ts new file mode 100644 index 0000000..4567f88 --- /dev/null +++ b/scripts/send-newsletter-stats.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env npx ts-node + +/** + * Send weekly stats summary email to developer + * + * Usage: + * npm run newsletter:send:stats + * + * Environment variables (from .env.local): + * - VITE_CONVEX_URL: Convex deployment URL + * - SITE_NAME: Your site name (default: "Newsletter") + * + * Note: AGENTMAIL_API_KEY, AGENTMAIL_INBOX, and AGENTMAIL_CONTACT_EMAIL must be set in Convex dashboard + */ + +import { ConvexHttpClient } from "convex/browser"; +import { api } from "../convex/_generated/api"; +import dotenv from "dotenv"; + +// Load environment variables +dotenv.config({ path: ".env.local" }); +dotenv.config({ path: ".env.production.local" }); + +async function main() { + const convexUrl = process.env.VITE_CONVEX_URL; + if (!convexUrl) { + console.error( + "Error: VITE_CONVEX_URL not found. Run 'npx convex dev' to create .env.local" + ); + process.exit(1); + } + + const siteName = process.env.SITE_NAME || "Newsletter"; + + console.log("Sending weekly stats summary..."); + console.log(`Site name: ${siteName}`); + console.log(""); + + const client = new ConvexHttpClient(convexUrl); + + try { + // Call the mutation to schedule the stats summary send + const result = await client.mutation(api.newsletter.scheduleSendStatsSummary, { + siteName, + }); + + if (result.success) { + console.log("āœ“ Stats summary scheduled successfully!"); + console.log(result.message); + console.log(""); + console.log("The email will be sent to AGENTMAIL_INBOX (or AGENTMAIL_CONTACT_EMAIL if set)."); + } else { + console.error("āœ— Failed to send stats summary:"); + console.error(result.message); + process.exit(1); + } + } catch (error) { + console.error("Error:", error); + process.exit(1); + } +} + +main(); diff --git a/scripts/send-newsletter.ts b/scripts/send-newsletter.ts new file mode 100644 index 0000000..748f2f3 --- /dev/null +++ b/scripts/send-newsletter.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env npx ts-node + +/** + * Send newsletter for a specific post to all subscribers + * + * Usage: + * npm run newsletter:send + * + * Example: + * npm run newsletter:send setup-guide + * + * Environment variables (from .env.local): + * - VITE_CONVEX_URL: Convex deployment URL + * - SITE_URL: Your site URL (default: https://markdown.fast) + * - SITE_NAME: Your site name (default: "Newsletter") + * + * Note: AGENTMAIL_API_KEY and AGENTMAIL_INBOX must be set in Convex dashboard + */ + +import { ConvexHttpClient } from "convex/browser"; +import { api } from "../convex/_generated/api"; +import dotenv from "dotenv"; + +// Load environment variables +dotenv.config({ path: ".env.local" }); +dotenv.config({ path: ".env.production.local" }); + +async function main() { + const postSlug = process.argv[2]; + + if (!postSlug) { + console.error("Usage: npm run newsletter:send "); + console.error("Example: npm run newsletter:send setup-guide"); + process.exit(1); + } + + const convexUrl = process.env.VITE_CONVEX_URL; + if (!convexUrl) { + console.error( + "Error: VITE_CONVEX_URL not found. Run 'npx convex dev' to create .env.local" + ); + process.exit(1); + } + + const siteUrl = process.env.SITE_URL || "https://markdown.fast"; + const siteName = process.env.SITE_NAME || "Newsletter"; + + console.log(`Sending newsletter for post: ${postSlug}`); + console.log(`Site URL: ${siteUrl}`); + console.log(`Site name: ${siteName}`); + console.log(""); + + const client = new ConvexHttpClient(convexUrl); + + try { + // First check if post exists + const post = await client.query(api.posts.getPostBySlug, { slug: postSlug }); + if (!post) { + console.error(`Error: Post "${postSlug}" not found or not published.`); + process.exit(1); + } + + console.log(`Found post: "${post.title}"`); + + // Get subscriber count first + const subscriberCount = await client.query(api.newsletter.getSubscriberCount); + console.log(`Active subscribers: ${subscriberCount}`); + + if (subscriberCount === 0) { + console.log("No subscribers to send to."); + process.exit(0); + } + + console.log(""); + console.log("Sending newsletter..."); + + // Call the mutation directly to schedule the newsletter send + const result = await client.mutation(api.newsletter.scheduleSendPostNewsletter, { + postSlug, + siteUrl, + siteName, + }); + + if (result.success) { + console.log("āœ“ Newsletter scheduled successfully!"); + console.log(result.message); + console.log(""); + console.log("The newsletter is being sent in the background."); + console.log("Check the Newsletter Admin page or recent sends to see results."); + } else { + console.error("āœ— Failed to send newsletter:"); + console.error(result.message); + process.exit(1); + } + } catch (error) { + console.error("Error:", error); + process.exit(1); + } +} + +main(); diff --git a/scripts/sync-posts.ts b/scripts/sync-posts.ts index 58107f4..3971036 100644 --- a/scripts/sync-posts.ts +++ b/scripts/sync-posts.ts @@ -39,8 +39,13 @@ interface PostFrontmatter { authorImage?: string; // Author avatar image URL (round) layout?: string; // Layout type: "sidebar" for docs-style layout rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled) + showFooter?: boolean; // Show footer on this post (overrides siteConfig default) + footer?: string; // Footer markdown content (overrides siteConfig defaultContent) + showSocialFooter?: boolean; // Show social footer on this post (overrides siteConfig default) aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true) blogFeatured?: boolean; // Show as hero featured post on /blog page + newsletter?: boolean; // Override newsletter signup display (true/false) + contactForm?: boolean; // Enable contact form on this post } interface ParsedPost { @@ -63,8 +68,11 @@ interface ParsedPost { rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled) showFooter?: boolean; // Show footer on this post (overrides siteConfig default) footer?: string; // Footer markdown content (overrides siteConfig defaultContent) + showSocialFooter?: boolean; // Show social footer on this post (overrides siteConfig default) aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true) blogFeatured?: boolean; // Show as hero featured post on /blog page + newsletter?: boolean; // Override newsletter signup display (true/false) + contactForm?: boolean; // Enable contact form on this post } // Page frontmatter (for static pages like About, Projects, Contact) @@ -84,7 +92,11 @@ interface PageFrontmatter { layout?: string; // Layout type: "sidebar" for docs-style layout rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled) showFooter?: boolean; // Show footer on this page (overrides siteConfig default) + footer?: string; // Footer markdown content (overrides siteConfig defaultContent) + showSocialFooter?: boolean; // Show social footer on this page (overrides siteConfig default) aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true) + contactForm?: boolean; // Enable contact form on this page + newsletter?: boolean; // Override newsletter signup display (true/false) } interface ParsedPage { @@ -104,7 +116,11 @@ interface ParsedPage { layout?: string; // Layout type: "sidebar" for docs-style layout rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled) showFooter?: boolean; // Show footer on this page (overrides siteConfig default) + footer?: string; // Footer markdown content (overrides siteConfig defaultContent) + showSocialFooter?: boolean; // Show social footer on this page (overrides siteConfig default) aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true) + contactForm?: boolean; // Enable contact form on this page + newsletter?: boolean; // Override newsletter signup display (true/false) } // Calculate reading time based on word count @@ -149,8 +165,11 @@ function parseMarkdownFile(filePath: string): ParsedPost | null { rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown showFooter: frontmatter.showFooter, // Show footer on this post footer: frontmatter.footer, // Footer markdown content + showSocialFooter: frontmatter.showSocialFooter, // Show social footer on this post aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar blogFeatured: frontmatter.blogFeatured, // Show as hero featured post on /blog page + newsletter: frontmatter.newsletter, // Override newsletter signup display + contactForm: frontmatter.contactForm, // Enable contact form on this post }; } catch (error) { console.error(`Error parsing ${filePath}:`, error); @@ -205,7 +224,11 @@ function parsePageFile(filePath: string): ParsedPage | null { layout: frontmatter.layout, // Layout type: "sidebar" for docs-style layout rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown showFooter: frontmatter.showFooter, // Show footer on this page + footer: frontmatter.footer, // Footer markdown content + showSocialFooter: frontmatter.showSocialFooter, // Show social footer on this page aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar + contactForm: frontmatter.contactForm, // Enable contact form on this page + newsletter: frontmatter.newsletter, // Override newsletter signup display }; } catch (error) { console.error(`Error parsing page ${filePath}:`, error); diff --git a/src/App.tsx b/src/App.tsx index b84eba7..788c65d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,8 @@ import Stats from "./pages/Stats"; import Blog from "./pages/Blog"; import Write from "./pages/Write"; import TagPage from "./pages/TagPage"; +import Unsubscribe from "./pages/Unsubscribe"; +import NewsletterAdmin from "./pages/NewsletterAdmin"; import Layout from "./components/Layout"; import { usePageTracking } from "./hooks/usePageTracking"; import { SidebarProvider } from "./context/SidebarContext"; @@ -20,6 +22,11 @@ function App() { return ; } + // Newsletter admin page renders without Layout (full-screen admin) + if (location.pathname === "/newsletter-admin") { + return ; + } + // Determine if we should use a custom homepage const useCustomHomepage = siteConfig.homepage.type !== "default" && siteConfig.homepage.slug; @@ -55,6 +62,8 @@ function App() { /> )} } /> + {/* Unsubscribe route for newsletter */} + } /> {/* Blog page route - only enabled when blogPage.enabled is true */} {siteConfig.blogPage.enabled && ( } /> diff --git a/src/components/BlogPost.tsx b/src/components/BlogPost.tsx index 0a5d4c9..70f0526 100644 --- a/src/components/BlogPost.tsx +++ b/src/components/BlogPost.tsx @@ -7,6 +7,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Copy, Check } from "lucide-react"; import { useTheme } from "../context/ThemeContext"; +import NewsletterSignup from "./NewsletterSignup"; +import ContactForm from "./ContactForm"; +import siteConfig from "../config/siteConfig"; // Sanitize schema that allows collapsible sections (details/summary) const sanitizeSchema = { @@ -261,6 +264,61 @@ const cursorTanTheme: { [key: string]: React.CSSProperties } = { interface BlogPostProps { content: string; + slug?: string; // For tracking source of newsletter/contact form signups + pageType?: "post" | "page"; // Type of content (for tracking) +} + +// Content segment types for inline embeds +type ContentSegment = + | { type: "content"; value: string } + | { type: "newsletter" } + | { type: "contactform" }; + +// Parse content for inline embed placeholders +// Supports: and +function parseContentForEmbeds(content: string): ContentSegment[] { + const segments: ContentSegment[] = []; + + // Pattern matches or (case insensitive) + const pattern = //gi; + + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(content)) !== null) { + // Add content before the placeholder + if (match.index > lastIndex) { + const textBefore = content.slice(lastIndex, match.index); + if (textBefore.trim()) { + segments.push({ type: "content", value: textBefore }); + } + } + + // Add the embed placeholder + const embedType = match[1].toLowerCase(); + if (embedType === "newsletter") { + segments.push({ type: "newsletter" }); + } else if (embedType === "contactform") { + segments.push({ type: "contactform" }); + } + + lastIndex = match.index + match[0].length; + } + + // Add remaining content after last placeholder + if (lastIndex < content.length) { + const remaining = content.slice(lastIndex); + if (remaining.trim()) { + segments.push({ type: "content", value: remaining }); + } + } + + // If no placeholders found, return single content segment + if (segments.length === 0) { + segments.push({ type: "content", value: content }); + } + + return segments; } // Generate slug from heading text for anchor links @@ -308,7 +366,7 @@ function HeadingAnchor({ id }: { id: string }) { ); } -export default function BlogPost({ content }: BlogPostProps) { +export default function BlogPost({ content, slug, pageType = "post" }: BlogPostProps) { const { theme } = useTheme(); const getCodeTheme = () => { @@ -324,6 +382,233 @@ export default function BlogPost({ content }: BlogPostProps) { } }; + // Parse content for inline embeds + const segments = parseContentForEmbeds(content); + const hasInlineEmbeds = segments.some((s) => s.type !== "content"); + + // Helper to render a single markdown segment + const renderMarkdown = (markdownContent: string, key?: number) => ( + + {children} + + ); + } + + const codeString = String(children).replace(/\n$/, ""); + const language = match ? match[1] : "text"; + const isTextBlock = language === "text"; + + // Custom styles for text blocks to enable wrapping + const textBlockStyle = isTextBlock ? { + whiteSpace: "pre-wrap" as const, + wordWrap: "break-word" as const, + overflowWrap: "break-word" as const, + } : {}; + + return ( +
    + {match && {match[1]}} + + + {codeString} + +
    + ); + }, + img({ src, alt }) { + return ( + + {alt + {alt && {alt}} + + ); + }, + a({ href, children }) { + const isExternal = href?.startsWith("http"); + return ( + + {children} + + ); + }, + blockquote({ children }) { + return ( +
    {children}
    + ); + }, + h1({ children }) { + const id = generateSlug(getTextContent(children)); + return ( +

    + + {children} +

    + ); + }, + h2({ children }) { + const id = generateSlug(getTextContent(children)); + return ( +

    + + {children} +

    + ); + }, + h3({ children }) { + const id = generateSlug(getTextContent(children)); + return ( +

    + + {children} +

    + ); + }, + h4({ children }) { + const id = generateSlug(getTextContent(children)); + return ( +

    + + {children} +

    + ); + }, + h5({ children }) { + const id = generateSlug(getTextContent(children)); + return ( +
    + + {children} +
    + ); + }, + h6({ children }) { + const id = generateSlug(getTextContent(children)); + return ( +
    + + {children} +
    + ); + }, + ul({ children }) { + return
      {children}
    ; + }, + ol({ children }) { + return
      {children}
    ; + }, + li({ children }) { + return
  • {children}
  • ; + }, + hr() { + return
    ; + }, + // Table components for GitHub-style tables + table({ children }) { + return ( +
    + {children}
    +
    + ); + }, + thead({ children }) { + return {children}; + }, + tbody({ children }) { + return {children}; + }, + tr({ children }) { + return {children}; + }, + th({ children }) { + return {children}; + }, + td({ children }) { + return {children}; + }, + }} + > + {markdownContent} +
    + ); + + // Build source string for tracking + const sourcePrefix = pageType === "page" ? "page" : "post"; + const source = slug ? `${sourcePrefix}:${slug}` : sourcePrefix; + + // Render with inline embeds if placeholders exist + if (hasInlineEmbeds) { + return ( +
    + {segments.map((segment, index) => { + if (segment.type === "newsletter") { + // Newsletter signup inline + return siteConfig.newsletter?.enabled ? ( + + ) : null; + } + if (segment.type === "contactform") { + // Contact form inline + return siteConfig.contactForm?.enabled ? ( + + ) : null; + } + // Markdown content segment + return renderMarkdown(segment.value, index); + })} +
    + ); + } + + // No inline embeds, render content normally return (
    ("idle"); + const [statusMessage, setStatusMessage] = useState(""); + + const submitContact = useMutation(api.contact.submitContact); + + // Check if contact form is enabled globally + if (!siteConfig.contactForm?.enabled) return null; + + // Use provided title/description or fall back to config defaults + const displayTitle = title || siteConfig.contactForm.title; + const displayDescription = description || siteConfig.contactForm.description; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Basic validation + if (!name.trim()) { + setStatus("error"); + setStatusMessage("Please enter your name."); + return; + } + + if (!email.trim()) { + setStatus("error"); + setStatusMessage("Please enter your email."); + return; + } + + if (!message.trim()) { + setStatus("error"); + setStatusMessage("Please enter a message."); + return; + } + + setStatus("loading"); + + try { + const result = await submitContact({ + name: name.trim(), + email: email.trim(), + message: message.trim(), + source, + }); + + if (result.success) { + setStatus("success"); + setStatusMessage(result.message); + // Clear form on success + setName(""); + setEmail(""); + setMessage(""); + } else { + setStatus("error"); + setStatusMessage(result.message); + } + } catch { + setStatus("error"); + setStatusMessage("Something went wrong. Please try again."); + } + }; + + return ( +
    +
    +

    {displayTitle}

    + {displayDescription && ( +

    {displayDescription}

    + )} + + {status === "success" ? ( +
    +

    {statusMessage}

    + +
    + ) : ( +
    +
    + + setName(e.target.value)} + placeholder="Your name" + className="contact-form__input" + disabled={status === "loading"} + /> +
    + +
    + + setEmail(e.target.value)} + placeholder="your@email.com" + className="contact-form__input" + disabled={status === "loading"} + /> +
    + +
    + +