diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8623fdf..432d65b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
- Contact list management with CSV/XLSX import and column mapping
- Email provider integration (Resend and Amazon SES) with encrypted credential storage
- Visual block email editor with drag-and-drop, live preview, and merge tags
+- Saved templates library with iframe thumbnail previews. Save campaigns as reusable templates, edit template content in place, start new campaigns prefilled from any template
- Campaign creation, sending, scheduling, and cancellation
- Open tracking (pixel) and click tracking (link wrapping) with per-campaign analytics
- Unsubscribe page with one-click opt-out and List-Unsubscribe header support
diff --git a/README.md b/README.md
index 8e07a6c..274bbc2 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Self-hostable broadcast email tool. Manage contact lists, design emails with a v
- **Contact Management** - Import contacts via CSV/XLSX upload, organize into lists, track subscription status
- **Visual Email Editor** - Drag-and-drop block editor with live preview, mobile/desktop toggle, and merge tag support
+- **Saved Templates Library** - Save any campaign as a reusable template, browse with live thumbnail previews, and start new campaigns from any template
- **Multiple Email Providers** - Send through Resend or Amazon SES with encrypted credential storage
- **Campaign Sending** - Queue-based sending via pg-boss, with scheduling, cancellation, and per-provider rate limiting
- **Open and Click Tracking** - Tracking pixel for opens, link wrapping for clicks, per-campaign analytics with charts
diff --git a/ROADMAP.md b/ROADMAP.md
index ad9cd7f..f2edf48 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -31,7 +31,7 @@ The features below build out the API surface so it's safe, observable, and compl
- [ ] **Segments** [Effort: M]: Saved filter queries against a list (e.g. "opened anything in the last 30 days, country = US"). Used as a campaign target instead of an entire list. Can be combined with tags, fields, and engagement signals.
- [ ] **Double opt-in** [Effort: M]: Per-list toggle that requires new contacts to confirm via emailed link before being marked active. Confirmation page and token-based confirmation flow.
- [ ] **Embeddable signup forms** [Effort: M]: Form builder, hosted form pages, and a JS embed snippet. Submissions flow into a list and respect double opt-in if enabled.
-- [ ] **Saved templates library** [Effort: S]: Reusable templates with thumbnails. Save any campaign as a template, start new campaigns from any template.
+- [x] **Saved templates library** [Effort: S]: Reusable templates with thumbnails. Save any campaign as a template, start new campaigns from any template.
- [ ] **Asset library** [Effort: M]: Central image and file manager. Upload once, browse and drop into any campaign editor. Replaces per-campaign image uploads.
- [ ] **A/B testing** [Effort: M]: Subject line and content variants on a campaign. Send variants to a test sample, pick the winner by open or click rate, and send the winner to the rest.
- [ ] **Reply-to and reply forwarding** [Effort: S]: Configurable reply-to address per campaign with optional forwarding to a real mailbox. Avoids no-reply senders without building a full inbox.
diff --git a/app/(dashboard)/editor/[campaignId]/page.tsx b/app/(dashboard)/editor/[campaignId]/page.tsx
index f3afbe5..a0c77ec 100644
--- a/app/(dashboard)/editor/[campaignId]/page.tsx
+++ b/app/(dashboard)/editor/[campaignId]/page.tsx
@@ -77,6 +77,12 @@ export default function EditorPage() {
const [testEmail, setTestEmail] = useState("")
const [sendingTest, setSendingTest] = useState(false)
+ // Save as template
+ const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
+ const [templateNameInput, setTemplateNameInput] = useState("")
+ const [templateDescriptionInput, setTemplateDescriptionInput] = useState("")
+ const [savingTemplate, setSavingTemplate] = useState(false)
+
// Merge tags
const [mergeTags, setMergeTags] = useState<{ tag: string; description: string }[]>([])
@@ -227,6 +233,46 @@ export default function EditorPage() {
}
}
+ const openSaveTemplate = () => {
+ setTemplateNameInput(name || "")
+ setTemplateDescriptionInput("")
+ setSaveTemplateOpen(true)
+ }
+
+ const handleSaveAsTemplate = async () => {
+ if (!templateNameInput.trim()) {
+ toast({ title: "Error", description: "Template name is required", variant: "destructive" })
+ return
+ }
+ setSavingTemplate(true)
+ try {
+ // Save current edits to the campaign first so the template snapshots the latest content
+ await saveDraft()
+ const res = await fetch(`/api/internal/campaigns/${campaignId}/save-as-template`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: templateNameInput.trim(),
+ description: templateDescriptionInput.trim() || null,
+ }),
+ })
+ const data = await res.json()
+ if (!res.ok) {
+ toast({ title: "Error", description: data.error || "Failed to save template", variant: "destructive" })
+ return
+ }
+ toast({
+ title: "Saved as template",
+ description: `Template "${data.name}" created. Find it in the Templates library.`,
+ })
+ setSaveTemplateOpen(false)
+ } catch {
+ toast({ title: "Error", description: "Failed to save template", variant: "destructive" })
+ } finally {
+ setSavingTemplate(false)
+ }
+ }
+
const copyMergeTag = (tag: string) => {
navigator.clipboard.writeText(tag)
toast({ title: "Copied", description: `${tag} copied to clipboard` })
@@ -302,12 +348,58 @@ export default function EditorPage() {
+
+ Save as Template
+
{saving ? "Saving..." : "Save Draft"}
+
+
+
+ Save as Template
+
+
+
+ Template Name
+ setTemplateNameInput(e.target.value)}
+ placeholder="e.g. Monthly Newsletter Layout"
+ />
+
+
+ Description (optional)
+ setTemplateDescriptionInput(e.target.value)}
+ placeholder="Short note about this template"
+ />
+
+
+ Snapshots the current subject, sender info, and email content. Saves your draft first.
+
+
+
+ setSaveTemplateOpen(false)}
+ disabled={savingTemplate}
+ >
+ Cancel
+
+
+ {savingTemplate ? "Saving..." : "Save Template"}
+
+
+
+
+
{/* Editor mode toggle + Subject and From fields */}
(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ const [editorMode, setEditorMode] = useState("visual")
+
+ const [name, setName] = useState("")
+ const [description, setDescription] = useState("")
+ const [subject, setSubject] = useState("")
+ const [fromName, setFromName] = useState("")
+ const [fromEmail, setFromEmail] = useState("")
+ const [blocks, setBlocks] = useState([])
+ const [templateHtml, setTemplateHtml] = useState("")
+
+ const [testDialogOpen, setTestDialogOpen] = useState(false)
+ const [testEmail, setTestEmail] = useState("")
+ const [sendingTest, setSendingTest] = useState(false)
+
+ const autoSaveRef = useRef | null>(null)
+ const hasChangesRef = useRef(false)
+
+ const fetchTemplate = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/internal/templates/${templateId}`)
+ if (!res.ok) {
+ toast({ title: "Error", description: "Template not found", variant: "destructive" })
+ router.push("/templates")
+ return
+ }
+ const data: Template = await res.json()
+ setTemplate(data)
+ setName(data.name)
+ setDescription(data.description ?? "")
+ setSubject(data.subject || "")
+ setFromName(data.fromName || "")
+ setFromEmail(data.fromEmail || "")
+ setBlocks(data.templateJson || [])
+ setTemplateHtml(data.templateHtml || "")
+ if (data.templateHtml && (!data.templateJson || data.templateJson.length === 0)) {
+ setEditorMode("code")
+ }
+ } catch {
+ toast({ title: "Error", description: "Failed to load template", variant: "destructive" })
+ } finally {
+ setLoading(false)
+ }
+ }, [templateId, router, toast])
+
+ useEffect(() => {
+ fetchTemplate()
+ }, [fetchTemplate])
+
+ const saveDraft = useCallback(async () => {
+ if (!template) return
+ setSaving(true)
+ try {
+ const res = await fetch(`/api/internal/templates/${templateId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name,
+ description: description || null,
+ subject,
+ fromName,
+ fromEmail,
+ templateJson: blocks,
+ templateHtml: templateHtml || null,
+ }),
+ })
+ if (!res.ok) throw new Error("Save failed")
+ hasChangesRef.current = false
+ toast({ title: "Saved", description: "Template saved" })
+ } catch {
+ toast({ title: "Error", description: "Failed to save template", variant: "destructive" })
+ } finally {
+ setSaving(false)
+ }
+ }, [template, templateId, name, description, subject, fromName, fromEmail, blocks, templateHtml, toast])
+
+ useEffect(() => {
+ autoSaveRef.current = setInterval(() => {
+ if (hasChangesRef.current && template) {
+ saveDraft()
+ }
+ }, 30000)
+ return () => {
+ if (autoSaveRef.current) clearInterval(autoSaveRef.current)
+ }
+ }, [saveDraft, template])
+
+ useEffect(() => {
+ if (template) {
+ hasChangesRef.current = true
+ }
+ }, [name, description, subject, fromName, fromEmail, blocks, templateHtml, template])
+
+ const handleTestSend = async () => {
+ if (!testEmail) return
+ setSendingTest(true)
+ try {
+ const res = await fetch(`/api/internal/templates/${templateId}/test-send`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ toEmail: testEmail }),
+ })
+ const data = await res.json()
+ if (!res.ok) {
+ toast({ title: "Error", description: data.error || "Failed to send test email", variant: "destructive" })
+ return
+ }
+ const sentList: string[] = data.sent || []
+ const failedList: { email: string; error: string }[] = data.failed || []
+ const sentMsg = sentList.length > 0
+ ? `Sent to ${sentList.length} recipient${sentList.length === 1 ? "" : "s"}`
+ : "Sent"
+ if (failedList.length > 0) {
+ toast({
+ title: "Partial success",
+ description: `${sentMsg}. Failed: ${failedList.map((f) => f.email).join(", ")}`,
+ variant: "destructive",
+ })
+ } else {
+ toast({ title: "Sent", description: sentMsg })
+ }
+ setTestDialogOpen(false)
+ setTestEmail("")
+ } catch {
+ toast({ title: "Error", description: "Failed to send test email", variant: "destructive" })
+ } finally {
+ setSendingTest(false)
+ }
+ }
+
+ const copyMergeTag = (tag: string) => {
+ navigator.clipboard.writeText(tag)
+ toast({ title: "Copied", description: `${tag} copied to clipboard` })
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!template) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ ← Templates
+
+ setName(e.target.value)}
+ className="font-semibold text-lg border-0 bg-transparent p-0 h-auto focus-visible:ring-0 max-w-xs"
+ placeholder="Template name"
+ />
+
+
+
+
+
+ Send Test
+
+
+
+
+ Send Test Email
+
+
+
+
Recipient Emails
+
setTestEmail(e.target.value)}
+ placeholder="test@example.com, another@example.com"
+ />
+
+ Separate multiple addresses with commas. Up to 10 recipients. Uses the default provider.
+
+
+
+
+
+ {sendingTest ? "Sending..." : "Send Test"}
+
+
+
+
+
+ {saving ? "Saving..." : "Save"}
+
+
+
+
+
+ setEditorMode("visual")}
+ className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
+ editorMode === "visual"
+ ? "bg-primary text-primary-foreground"
+ : "bg-secondary text-muted-foreground hover:bg-secondary/80"
+ }`}
+ >
+ Visual Editor
+
+ setEditorMode("code")}
+ className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
+ editorMode === "code"
+ ? "bg-primary text-primary-foreground"
+ : "bg-secondary text-muted-foreground hover:bg-secondary/80"
+ }`}
+ >
+ HTML Code
+
+
+
+
+
+
+ {editorMode === "visual" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ Merge tags:
+ {DEFAULT_MERGE_TAGS.map((item) => (
+ copyMergeTag(item.tag)}
+ title={item.description}
+ className="text-xs px-2 py-1 bg-primary/10 text-primary hover:bg-primary/20 rounded-md font-mono transition-colors"
+ >
+ {item.tag}
+
+ ))}
+
+
+
+ )
+}
diff --git a/app/(dashboard)/templates/page.tsx b/app/(dashboard)/templates/page.tsx
new file mode 100644
index 0000000..019b295
--- /dev/null
+++ b/app/(dashboard)/templates/page.tsx
@@ -0,0 +1,519 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+import { useRouter } from "next/navigation"
+import Link from "next/link"
+import { format } from "date-fns"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { useToast } from "@/components/ui/use-toast"
+import { Skeleton } from "@/components/ui/skeleton"
+import type { Block } from "@/lib/db/schema"
+
+interface Template {
+ id: string
+ name: string
+ description: string | null
+ subject: string
+ fromName: string
+ fromEmail: string
+ templateJson: Block[]
+ templateHtml: string | null
+ createdAt: string
+ updatedAt: string
+}
+
+interface ListOption {
+ id: string
+ name: string
+}
+
+// Minimal client-side block renderer for thumbnail previews. Mirrors lib/renderer
+// but avoids pulling Handlebars and juice into the client bundle.
+function renderBlocksForPreview(blocks: Block[]): string {
+ return blocks
+ .map((block) => {
+ const p = (block.props || {}) as Record
+ switch (block.type) {
+ case "heading":
+ return `${p.text || ""} `
+ case "text":
+ return `${p.text || ""}
`
+ case "button":
+ return ``
+ case "image":
+ return ` `
+ case "divider":
+ return ` `
+ case "spacer":
+ return `
`
+ default:
+ return ""
+ }
+ })
+ .join("\n")
+}
+
+function buildPreviewSrcDoc(template: Template): string {
+ const inner =
+ template.templateHtml && template.templateHtml.trim().length > 0
+ ? template.templateHtml
+ : renderBlocksForPreview(template.templateJson || [])
+ if (!inner.trim()) {
+ return `Empty template`
+ }
+ return `${inner}`
+}
+
+function TemplateThumbnail({ template }: { template: Template }) {
+ const srcDoc = useMemo(() => buildPreviewSrcDoc(template), [template])
+ return (
+
+
+
+ )
+}
+
+export default function TemplatesPage() {
+ const router = useRouter()
+ const { toast } = useToast()
+
+ const [templates, setTemplates] = useState([])
+ const [lists, setLists] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ // New template dialog
+ const [newOpen, setNewOpen] = useState(false)
+ const [newName, setNewName] = useState("")
+ const [newDescription, setNewDescription] = useState("")
+ const [creating, setCreating] = useState(false)
+
+ // Use template dialog
+ const [useOpen, setUseOpen] = useState(false)
+ const [useTemplate, setUseTemplate] = useState(null)
+ const [useCampaignName, setUseCampaignName] = useState("")
+ const [useListId, setUseListId] = useState("")
+ const [using, setUsing] = useState(false)
+
+ // Rename dialog
+ const [renameOpen, setRenameOpen] = useState(false)
+ const [renameTemplate, setRenameTemplate] = useState(null)
+ const [renameName, setRenameName] = useState("")
+ const [renameDescription, setRenameDescription] = useState("")
+
+ async function load() {
+ setLoading(true)
+ try {
+ const [tplRes, listsRes] = await Promise.all([
+ fetch("/api/internal/templates"),
+ fetch("/api/internal/lists"),
+ ])
+ if (!tplRes.ok || !listsRes.ok) {
+ toast({ title: "Failed to load data", variant: "destructive" })
+ return
+ }
+ setTemplates(await tplRes.json())
+ setLists(await listsRes.json())
+ } catch {
+ toast({ title: "An error occurred while loading templates", variant: "destructive" })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ load()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ function openNew() {
+ setNewName("")
+ setNewDescription("")
+ setNewOpen(true)
+ }
+
+ async function handleCreate() {
+ if (!newName.trim()) {
+ toast({ title: "Template name is required", variant: "destructive" })
+ return
+ }
+ setCreating(true)
+ try {
+ const res = await fetch("/api/internal/templates", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: newName.trim(),
+ description: newDescription.trim() || null,
+ }),
+ })
+ if (!res.ok) {
+ const err = await res.json()
+ toast({ title: err.error || "Failed to create template", variant: "destructive" })
+ return
+ }
+ const created = await res.json()
+ setNewOpen(false)
+ router.push(`/templates/${created.id}/edit`)
+ } catch {
+ toast({ title: "Failed to create template", variant: "destructive" })
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ function openUse(template: Template) {
+ setUseTemplate(template)
+ setUseCampaignName(template.name)
+ setUseListId("")
+ setUseOpen(true)
+ }
+
+ async function handleUse() {
+ if (!useTemplate) return
+ if (!useCampaignName.trim()) {
+ toast({ title: "Campaign name is required", variant: "destructive" })
+ return
+ }
+ if (!useListId) {
+ toast({ title: "Please select a list", variant: "destructive" })
+ return
+ }
+ setUsing(true)
+ try {
+ const res = await fetch(`/api/internal/templates/${useTemplate.id}/use`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: useCampaignName.trim(), listId: useListId }),
+ })
+ if (!res.ok) {
+ const err = await res.json()
+ toast({ title: err.error || "Failed to create campaign", variant: "destructive" })
+ return
+ }
+ const created = await res.json()
+ setUseOpen(false)
+ router.push(`/editor/${created.id}`)
+ } catch {
+ toast({ title: "Failed to create campaign", variant: "destructive" })
+ } finally {
+ setUsing(false)
+ }
+ }
+
+ function openRename(template: Template) {
+ setRenameTemplate(template)
+ setRenameName(template.name)
+ setRenameDescription(template.description ?? "")
+ setRenameOpen(true)
+ }
+
+ async function handleRename() {
+ if (!renameTemplate) return
+ if (!renameName.trim()) {
+ toast({ title: "Template name is required", variant: "destructive" })
+ return
+ }
+ try {
+ const res = await fetch(`/api/internal/templates/${renameTemplate.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: renameName.trim(),
+ description: renameDescription.trim() || null,
+ }),
+ })
+ if (!res.ok) {
+ const err = await res.json()
+ toast({ title: err.error || "Failed to rename template", variant: "destructive" })
+ return
+ }
+ setRenameOpen(false)
+ await load()
+ } catch {
+ toast({ title: "Failed to rename template", variant: "destructive" })
+ }
+ }
+
+ async function handleDelete(template: Template) {
+ if (!confirm(`Delete template "${template.name}"? This cannot be undone.`)) return
+ try {
+ const res = await fetch(`/api/internal/templates/${template.id}`, {
+ method: "DELETE",
+ })
+ if (!res.ok) {
+ const err = await res.json()
+ toast({ title: err.error || "Failed to delete template", variant: "destructive" })
+ return
+ }
+ toast({ title: "Template deleted" })
+ await load()
+ } catch {
+ toast({ title: "Failed to delete template", variant: "destructive" })
+ }
+ }
+
+ return (
+
+
+
+
Templates
+
+ Reusable layouts. Save any campaign as a template, start new campaigns from one.
+
+
+
New Template
+
+
+ {loading ? (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ ) : templates.length === 0 ? (
+
+
No templates yet
+
+ Create a template, or save an existing campaign as one.
+
+
+ New Template
+
+
+ ) : (
+
+ {templates.map((template) => (
+
+
+
+
+
+
+
+ {template.name}
+
+
+
+
+
+
+
+
+
+
+
+
+ openRename(template)}>
+ Rename
+
+ handleDelete(template)}
+ >
+ Delete
+
+
+
+
+ {template.description ? (
+
+ {template.description}
+
+ ) : null}
+
+ Updated {format(new Date(template.updatedAt), "MMM d, yyyy")}
+
+
+ openUse(template)}
+ >
+ Use
+
+
+ Edit
+
+
+
+
+ ))}
+
+ )}
+
+ {/* New template dialog */}
+
+
+
+ New Template
+
+
+
+ setNewOpen(false)} disabled={creating}>
+ Cancel
+
+
+ {creating ? "Creating..." : "Create Template"}
+
+
+
+
+
+ {/* Use template dialog */}
+
+
+
+ Start Campaign From Template
+
+
+
+ Campaign Name
+ setUseCampaignName(e.target.value)}
+ />
+
+
+ List
+
+
+
+
+
+ {lists.length === 0 ? (
+
+ No lists available
+
+ ) : (
+ lists.map((list) => (
+
+ {list.name}
+
+ ))
+ )}
+
+
+
+
+
+ setUseOpen(false)} disabled={using}>
+ Cancel
+
+
+ {using ? "Creating..." : "Create Campaign"}
+
+
+
+
+
+ {/* Rename dialog */}
+
+
+
+ Edit Template Details
+
+
+
+ setRenameOpen(false)}>
+ Cancel
+
+ Save
+
+
+
+
+ )
+}
diff --git a/app/api/internal/campaigns/[id]/save-as-template/route.ts b/app/api/internal/campaigns/[id]/save-as-template/route.ts
new file mode 100644
index 0000000..5fa090c
--- /dev/null
+++ b/app/api/internal/campaigns/[id]/save-as-template/route.ts
@@ -0,0 +1,59 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { campaigns, templates } from '@/lib/db/schema'
+import { eq } from 'drizzle-orm'
+import { auditFromSession, logAudit } from '@/lib/audit'
+import { saveAsTemplateSchema } from '@/lib/validations/templates'
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ let body: unknown
+ try {
+ body = await req.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
+ }
+
+ const parsed = saveAsTemplateSchema.safeParse(body)
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: parsed.error.flatten() },
+ { status: 400 }
+ )
+ }
+
+ const { name, description } = parsed.data
+
+ const [campaign] = await db
+ .select()
+ .from(campaigns)
+ .where(eq(campaigns.id, params.id))
+
+ if (!campaign) {
+ return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
+ }
+
+ const [created] = await db
+ .insert(templates)
+ .values({
+ name,
+ description: description ?? null,
+ subject: campaign.subject,
+ fromName: campaign.fromName,
+ fromEmail: campaign.fromEmail,
+ templateJson: campaign.templateJson,
+ templateHtml: campaign.templateHtml,
+ })
+ .returning()
+
+ await logAudit(
+ await auditFromSession(req),
+ 'template.create_from_campaign',
+ { type: 'template', id: created.id },
+ { campaignId: campaign.id, name: created.name },
+ )
+
+ return NextResponse.json(created, { status: 201 })
+}
diff --git a/app/api/internal/templates/[id]/route.ts b/app/api/internal/templates/[id]/route.ts
new file mode 100644
index 0000000..4b370f4
--- /dev/null
+++ b/app/api/internal/templates/[id]/route.ts
@@ -0,0 +1,92 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { templates } from '@/lib/db/schema'
+import { eq } from 'drizzle-orm'
+import { auditFromSession, logAudit } from '@/lib/audit'
+import { updateTemplateSchema } from '@/lib/validations/templates'
+
+export async function GET(
+ _req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ const [template] = await db
+ .select()
+ .from(templates)
+ .where(eq(templates.id, params.id))
+
+ if (!template) {
+ return NextResponse.json({ error: 'Template not found' }, { status: 404 })
+ }
+
+ return NextResponse.json(template)
+}
+
+export async function PATCH(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ let body: unknown
+ try {
+ body = await req.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
+ }
+
+ const parsed = updateTemplateSchema.safeParse(body)
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: parsed.error.flatten() },
+ { status: 400 }
+ )
+ }
+
+ const data = parsed.data
+ if (Object.keys(data).length === 0) {
+ return NextResponse.json(
+ { error: 'No valid fields provided for update' },
+ { status: 400 }
+ )
+ }
+
+ const [updated] = await db
+ .update(templates)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(templates.id, params.id))
+ .returning()
+
+ if (!updated) {
+ return NextResponse.json({ error: 'Template not found' }, { status: 404 })
+ }
+
+ await logAudit(
+ await auditFromSession(req),
+ 'template.update',
+ { type: 'template', id: updated.id },
+ { fields: Object.keys(data) },
+ )
+
+ return NextResponse.json(updated)
+}
+
+export async function DELETE(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ const [deleted] = await db
+ .delete(templates)
+ .where(eq(templates.id, params.id))
+ .returning()
+
+ if (!deleted) {
+ return NextResponse.json({ error: 'Template not found' }, { status: 404 })
+ }
+
+ await logAudit(
+ await auditFromSession(req),
+ 'template.delete',
+ { type: 'template', id: deleted.id },
+ { name: deleted.name },
+ )
+
+ return NextResponse.json({ success: true })
+}
diff --git a/app/api/internal/templates/[id]/test-send/route.ts b/app/api/internal/templates/[id]/test-send/route.ts
new file mode 100644
index 0000000..53fe823
--- /dev/null
+++ b/app/api/internal/templates/[id]/test-send/route.ts
@@ -0,0 +1,165 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { templates, emailProviders } from '@/lib/db/schema'
+import { eq } from 'drizzle-orm'
+import { createProviderAdapter } from '@/lib/providers/factory'
+import { renderTemplate, renderPlainText } from '@/lib/renderer'
+import { logger, trackEvent, trackError } from '@/lib/logger'
+import { auditFromSession, logAudit } from '@/lib/audit'
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ const startTime = Date.now()
+
+ try {
+ const body = await req.json()
+ const { toEmail } = body
+
+ if (!toEmail || typeof toEmail !== 'string') {
+ return NextResponse.json({ error: 'toEmail is required' }, { status: 400 })
+ }
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ const recipients = Array.from(
+ new Set(
+ toEmail
+ .split(',')
+ .map((e) => e.trim())
+ .filter((e) => e.length > 0)
+ )
+ )
+
+ if (recipients.length === 0) {
+ return NextResponse.json(
+ { error: 'At least one recipient email is required' },
+ { status: 400 }
+ )
+ }
+
+ const invalidEmails = recipients.filter((e) => !emailRegex.test(e))
+ if (invalidEmails.length > 0) {
+ return NextResponse.json(
+ { error: `Invalid email address(es): ${invalidEmails.join(', ')}` },
+ { status: 400 }
+ )
+ }
+
+ if (recipients.length > 10) {
+ return NextResponse.json(
+ { error: 'Cannot send a test to more than 10 recipients at once' },
+ { status: 400 }
+ )
+ }
+
+ const [template] = await db
+ .select()
+ .from(templates)
+ .where(eq(templates.id, params.id))
+
+ if (!template) {
+ return NextResponse.json({ error: 'Template not found' }, { status: 404 })
+ }
+
+ const [provider] = await db
+ .select()
+ .from(emailProviders)
+ .where(eq(emailProviders.isDefault, true))
+ .limit(1)
+
+ if (!provider) {
+ return NextResponse.json(
+ { error: 'No default email provider configured. Set one in Settings, Providers.' },
+ { status: 400 }
+ )
+ }
+
+ const hasContent =
+ (template.templateJson && template.templateJson.length > 0) ||
+ (template.templateHtml && template.templateHtml.trim().length > 0)
+ if (!hasContent) {
+ return NextResponse.json(
+ { error: 'Template has no email content. Add content in the editor and save before sending.' },
+ { status: 400 }
+ )
+ }
+
+ if (!template.fromEmail || !emailRegex.test(template.fromEmail)) {
+ return NextResponse.json(
+ { error: 'Template "From email" is missing or invalid. Set a sender address in the editor.' },
+ { status: 400 }
+ )
+ }
+
+ if (!template.fromName || !template.fromName.trim()) {
+ return NextResponse.json(
+ { error: 'Template "From name" is missing. Set a sender name in the editor.' },
+ { status: 400 }
+ )
+ }
+
+ const adapter = createProviderAdapter(provider.type, provider.configEncrypted)
+ const appUrl = process.env.APP_URL!
+ const testUnsubscribeUrl = `${appUrl}/unsubscribe/00000000-0000-0000-0000-000000000000`
+
+ const sent: string[] = []
+ const failed: { email: string; error: string }[] = []
+
+ for (const recipient of recipients) {
+ try {
+ const html = renderTemplate({
+ blocks: template.templateJson,
+ contact: { email: recipient, first_name: 'Test', last_name: 'User', unsubscribe_url: testUnsubscribeUrl },
+ sendId: 'test-' + Date.now(),
+ appUrl,
+ unsubscribeUrl: testUnsubscribeUrl,
+ rawHtml: template.templateHtml,
+ })
+
+ await adapter.send({
+ to: recipient,
+ from: template.fromEmail,
+ fromName: template.fromName,
+ subject: template.subject || 'Test Email',
+ html,
+ text: renderPlainText(html),
+ })
+ sent.push(recipient)
+ } catch (sendErr) {
+ const message = sendErr instanceof Error ? sendErr.message : 'Send failed'
+ logger.error({ recipient, err: sendErr }, 'Template test send failed')
+ failed.push({ email: recipient, error: message })
+ }
+ }
+
+ const durationMs = Date.now() - startTime
+ trackEvent('template_test_email_sent', {
+ templateId: params.id,
+ providerType: provider.type,
+ sentCount: sent.length,
+ failedCount: failed.length,
+ durationMs,
+ })
+
+ if (sent.length === 0) {
+ return NextResponse.json(
+ { error: failed[0]?.error || 'Failed to send test emails', failed },
+ { status: 500 }
+ )
+ }
+
+ await logAudit(
+ await auditFromSession(req),
+ 'template.test_send',
+ { type: 'template', id: template.id },
+ { recipients: sent, failedCount: failed.length },
+ )
+
+ return NextResponse.json({ success: true, sent, failed })
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to send test email'
+ trackError(err, { action: 'template_test_send', templateId: params.id })
+ return NextResponse.json({ error: message }, { status: 500 })
+ }
+}
diff --git a/app/api/internal/templates/[id]/use/route.ts b/app/api/internal/templates/[id]/use/route.ts
new file mode 100644
index 0000000..b7c5e18
--- /dev/null
+++ b/app/api/internal/templates/[id]/use/route.ts
@@ -0,0 +1,76 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { templates, campaigns, emailProviders, lists } from '@/lib/db/schema'
+import { eq } from 'drizzle-orm'
+import { auditFromSession, logAudit } from '@/lib/audit'
+import { useTemplateSchema } from '@/lib/validations/templates'
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ let body: unknown
+ try {
+ body = await req.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
+ }
+
+ const parsed = useTemplateSchema.safeParse(body)
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: parsed.error.flatten() },
+ { status: 400 }
+ )
+ }
+
+ const { name, listId } = parsed.data
+
+ const [template] = await db
+ .select()
+ .from(templates)
+ .where(eq(templates.id, params.id))
+
+ if (!template) {
+ return NextResponse.json({ error: 'Template not found' }, { status: 404 })
+ }
+
+ const [list] = await db
+ .select({ id: lists.id })
+ .from(lists)
+ .where(eq(lists.id, listId))
+ .limit(1)
+ if (!list) {
+ return NextResponse.json({ error: 'List not found' }, { status: 404 })
+ }
+
+ const [defaultProvider] = await db
+ .select({ id: emailProviders.id })
+ .from(emailProviders)
+ .where(eq(emailProviders.isDefault, true))
+ .limit(1)
+
+ const [created] = await db
+ .insert(campaigns)
+ .values({
+ name,
+ listId,
+ status: 'draft',
+ subject: template.subject,
+ fromName: template.fromName,
+ fromEmail: template.fromEmail,
+ templateJson: template.templateJson,
+ templateHtml: template.templateHtml,
+ providerId: defaultProvider?.id ?? null,
+ })
+ .returning()
+
+ await logAudit(
+ await auditFromSession(req),
+ 'campaign.create_from_template',
+ { type: 'campaign', id: created.id },
+ { templateId: template.id, templateName: template.name, listId },
+ )
+
+ return NextResponse.json(created, { status: 201 })
+}
diff --git a/app/api/internal/templates/route.ts b/app/api/internal/templates/route.ts
new file mode 100644
index 0000000..7e8d4bd
--- /dev/null
+++ b/app/api/internal/templates/route.ts
@@ -0,0 +1,77 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { templates, campaigns } from '@/lib/db/schema'
+import { desc, eq } from 'drizzle-orm'
+import { auditFromSession, logAudit } from '@/lib/audit'
+import { createTemplateSchema } from '@/lib/validations/templates'
+
+export async function GET() {
+ const rows = await db
+ .select()
+ .from(templates)
+ .orderBy(desc(templates.updatedAt))
+
+ return NextResponse.json(rows)
+}
+
+export async function POST(req: NextRequest) {
+ let body: unknown
+ try {
+ body = await req.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
+ }
+
+ const parsed = createTemplateSchema.safeParse(body)
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'Validation failed', details: parsed.error.flatten() },
+ { status: 400 }
+ )
+ }
+
+ const { name, description, fromCampaignId } = parsed.data
+
+ let snapshot = {
+ subject: '',
+ fromName: '',
+ fromEmail: '',
+ templateJson: [] as never[],
+ templateHtml: null as string | null,
+ }
+
+ if (fromCampaignId) {
+ const [campaign] = await db
+ .select()
+ .from(campaigns)
+ .where(eq(campaigns.id, fromCampaignId))
+ if (!campaign) {
+ return NextResponse.json({ error: 'Source campaign not found' }, { status: 404 })
+ }
+ snapshot = {
+ subject: campaign.subject,
+ fromName: campaign.fromName,
+ fromEmail: campaign.fromEmail,
+ templateJson: campaign.templateJson as never[],
+ templateHtml: campaign.templateHtml,
+ }
+ }
+
+ const [created] = await db
+ .insert(templates)
+ .values({
+ name,
+ description: description ?? null,
+ ...snapshot,
+ })
+ .returning()
+
+ await logAudit(
+ await auditFromSession(req),
+ 'template.create',
+ { type: 'template', id: created.id },
+ { name: created.name, fromCampaignId: fromCampaignId ?? null },
+ )
+
+ return NextResponse.json(created, { status: 201 })
+}
diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx
index 4360718..af97085 100644
--- a/components/dashboard/sidebar.tsx
+++ b/components/dashboard/sidebar.tsx
@@ -46,6 +46,17 @@ const mainNavItems: NavItem[] = [
),
},
+ {
+ label: 'Templates',
+ href: '/templates',
+ icon: (
+
+
+
+
+
+ ),
+ },
]
const settingsNavItems: NavItem[] = [
diff --git a/drizzle/migrations/0002_old_starbolt.sql b/drizzle/migrations/0002_old_starbolt.sql
new file mode 100644
index 0000000..b7b4b18
--- /dev/null
+++ b/drizzle/migrations/0002_old_starbolt.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS "templates" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "description" text,
+ "subject" text DEFAULT '' NOT NULL,
+ "from_name" text DEFAULT '' NOT NULL,
+ "from_email" text DEFAULT '' NOT NULL,
+ "template_json" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "template_html" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
diff --git a/drizzle/migrations/meta/0002_snapshot.json b/drizzle/migrations/meta/0002_snapshot.json
new file mode 100644
index 0000000..9530ed0
--- /dev/null
+++ b/drizzle/migrations/meta/0002_snapshot.json
@@ -0,0 +1,901 @@
+{
+ "id": "75b7bc78-987d-4096-bf9f-7291a1f0782c",
+ "prevId": "378e93f9-e1c7-4ee8-bdaf-4e645c039b90",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.api_keys": {
+ "name": "api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rate_limit_per_minute": {
+ "name": "rate_limit_per_minute",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "rate_limit_tokens": {
+ "name": "rate_limit_tokens",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "rate_limit_updated_at": {
+ "name": "rate_limit_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_logs": {
+ "name": "audit_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_label": {
+ "name": "actor_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_type": {
+ "name": "resource_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'::jsonb"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_logs_created_at_idx": {
+ "name": "audit_logs_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_resource_idx": {
+ "name": "audit_logs_resource_idx",
+ "columns": [
+ {
+ "expression": "resource_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.campaign_events": {
+ "name": "campaign_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "campaign_send_id": {
+ "name": "campaign_send_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "campaign_id": {
+ "name": "campaign_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "link_url": {
+ "name": "link_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "campaign_events_campaign_send_id_campaign_sends_id_fk": {
+ "name": "campaign_events_campaign_send_id_campaign_sends_id_fk",
+ "tableFrom": "campaign_events",
+ "tableTo": "campaign_sends",
+ "columnsFrom": [
+ "campaign_send_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "campaign_events_campaign_id_campaigns_id_fk": {
+ "name": "campaign_events_campaign_id_campaigns_id_fk",
+ "tableFrom": "campaign_events",
+ "tableTo": "campaigns",
+ "columnsFrom": [
+ "campaign_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.campaign_sends": {
+ "name": "campaign_sends",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "campaign_id": {
+ "name": "campaign_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "campaign_sends_campaign_id_campaigns_id_fk": {
+ "name": "campaign_sends_campaign_id_campaigns_id_fk",
+ "tableFrom": "campaign_sends",
+ "tableTo": "campaigns",
+ "columnsFrom": [
+ "campaign_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "campaign_sends_contact_id_contacts_id_fk": {
+ "name": "campaign_sends_contact_id_contacts_id_fk",
+ "tableFrom": "campaign_sends",
+ "tableTo": "contacts",
+ "columnsFrom": [
+ "contact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "campaign_sends_campaign_id_contact_id_unique": {
+ "name": "campaign_sends_campaign_id_contact_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "campaign_id",
+ "contact_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.campaigns": {
+ "name": "campaigns",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "from_email": {
+ "name": "from_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "list_id": {
+ "name": "list_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "template_json": {
+ "name": "template_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "template_html": {
+ "name": "template_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_recipients": {
+ "name": "total_recipients",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancel_requested": {
+ "name": "cancel_requested",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "campaigns_list_id_lists_id_fk": {
+ "name": "campaigns_list_id_lists_id_fk",
+ "tableFrom": "campaigns",
+ "tableTo": "lists",
+ "columnsFrom": [
+ "list_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "campaigns_provider_id_email_providers_id_fk": {
+ "name": "campaigns_provider_id_email_providers_id_fk",
+ "tableFrom": "campaigns",
+ "tableTo": "email_providers",
+ "columnsFrom": [
+ "provider_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contacts": {
+ "name": "contacts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "list_id": {
+ "name": "list_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "unsubscribe_token": {
+ "name": "unsubscribe_token",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contacts_list_id_lists_id_fk": {
+ "name": "contacts_list_id_lists_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "lists",
+ "columnsFrom": [
+ "list_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "contacts_unsubscribe_token_unique": {
+ "name": "contacts_unsubscribe_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "unsubscribe_token"
+ ]
+ },
+ "contacts_list_id_email_unique": {
+ "name": "contacts_list_id_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "list_id",
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_providers": {
+ "name": "email_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config_encrypted": {
+ "name": "config_encrypted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rate_limit_per_second": {
+ "name": "rate_limit_per_second",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 10
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.lists": {
+ "name": "lists",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.suppressions": {
+ "name": "suppressions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "suppressions_email_unique": {
+ "name": "suppressions_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.templates": {
+ "name": "templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "from_email": {
+ "name": "from_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "template_json": {
+ "name": "template_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "template_html": {
+ "name": "template_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json
index 7c2334f..831ce33 100644
--- a/drizzle/migrations/meta/_journal.json
+++ b/drizzle/migrations/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1777752836747,
"tag": "0001_woozy_silvermane",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1777840693279,
+ "tag": "0002_old_starbolt",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 10c7786..885f5fb 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -87,6 +87,19 @@ export const suppressions = pgTable('suppressions', {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
+export const templates = pgTable('templates', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ name: text('name').notNull(),
+ description: text('description'),
+ subject: text('subject').notNull().default(''),
+ fromName: text('from_name').notNull().default(''),
+ fromEmail: text('from_email').notNull().default(''),
+ templateJson: jsonb('template_json').notNull().default([]).$type(),
+ templateHtml: text('template_html'),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+})
+
export const apiKeys = pgTable('api_keys', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
@@ -134,3 +147,4 @@ export type CampaignEvent = typeof campaignEvents.$inferSelect
export type ApiKey = typeof apiKeys.$inferSelect
export type Suppression = typeof suppressions.$inferSelect
export type AuditLog = typeof auditLogs.$inferSelect
+export type Template = typeof templates.$inferSelect
diff --git a/lib/validations/templates.ts b/lib/validations/templates.ts
new file mode 100644
index 0000000..8401fdd
--- /dev/null
+++ b/lib/validations/templates.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod'
+
+const blockSchema = z.object({
+ id: z.string(),
+ type: z.enum(['heading', 'text', 'image', 'button', 'divider', 'spacer']),
+ props: z.record(z.unknown()),
+})
+
+export const createTemplateSchema = z.object({
+ name: z.string().min(1, 'Name is required'),
+ description: z.string().optional().nullable(),
+ fromCampaignId: z.string().uuid().optional(),
+})
+
+export const updateTemplateSchema = z.object({
+ name: z.string().min(1).optional(),
+ description: z.string().nullable().optional(),
+ subject: z.string().optional(),
+ fromName: z.string().optional(),
+ fromEmail: z.string().optional(),
+ templateJson: z.array(blockSchema).optional(),
+ templateHtml: z.string().nullable().optional(),
+})
+
+export const useTemplateSchema = z.object({
+ name: z.string().min(1, 'Name is required'),
+ listId: z.string().uuid(),
+})
+
+export const saveAsTemplateSchema = z.object({
+ name: z.string().min(1, 'Name is required'),
+ description: z.string().optional().nullable(),
+})
+
+export type CreateTemplateInput = z.infer
+export type UpdateTemplateInput = z.infer
+export type UseTemplateInput = z.infer
+export type SaveAsTemplateInput = z.infer