Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
92 changes: 92 additions & 0 deletions app/(dashboard)/editor/[campaignId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[]>([])

Expand Down Expand Up @@ -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` })
Expand Down Expand Up @@ -302,12 +348,58 @@ export default function EditorPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<Button variant="outline" size="sm" onClick={openSaveTemplate}>
Save as Template
</Button>
<Button onClick={saveDraft} disabled={saving} size="sm">
{saving ? "Saving..." : "Save Draft"}
</Button>
</div>
</div>

<Dialog open={saveTemplateOpen} onOpenChange={setSaveTemplateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save as Template</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="save-template-name">Template Name</Label>
<Input
id="save-template-name"
value={templateNameInput}
onChange={(e) => setTemplateNameInput(e.target.value)}
placeholder="e.g. Monthly Newsletter Layout"
/>
</div>
<div className="space-y-2">
<Label htmlFor="save-template-description">Description (optional)</Label>
<Input
id="save-template-description"
value={templateDescriptionInput}
onChange={(e) => setTemplateDescriptionInput(e.target.value)}
placeholder="Short note about this template"
/>
</div>
<p className="text-xs text-muted-foreground">
Snapshots the current subject, sender info, and email content. Saves your draft first.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSaveTemplateOpen(false)}
disabled={savingTemplate}
>
Cancel
</Button>
<Button onClick={handleSaveAsTemplate} disabled={savingTemplate}>
{savingTemplate ? "Saving..." : "Save Template"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Editor mode toggle + Subject and From fields */}
<div className="flex items-center gap-1 mt-3 mb-2">
<button
Expand Down
Loading
Loading