AI Email Campaign Builder
Build an AI-powered email campaign builder that generates copy, pulls audience data, and saves campaigns to cloud storage.
Build an AI Email Campaign Builder
In this tutorial, you'll build a full email campaign creation tool. The app generates subject lines and email body copy using AI, lets the merchant pick a Klaviyo audience segment, previews the email, and saves drafts to Reeve cloud storage.
What you'll build:
- Campaign brief form (product, tone, goal)
- AI-generated subject line + email body with streaming output
- Segment picker pulling live Klaviyo audiences
- Draft management with save/load/delete via
reeve.storage
APIs used: reeve.ai.stream, reeve.data.query, reeve.storage
Time: ~20 minutes
1. Scaffold and Configure
npx create-reeve-app email-builder --template react-app
cd email-builder
npm install
npm run devUpdate reeve-manifest.json:
{
"id": "com.yourcompany.email-builder",
"name": "AI Campaign Builder",
"version": "1.0.0",
"description": "Generate, preview, and save AI email campaigns in seconds.",
"author": {
"name": "Your Name",
"email": "you@yourcompany.com"
},
"category": "email",
"tags": ["klaviyo", "email", "ai", "campaigns"],
"permissions": [
"ai.complete",
"ai.stream",
"data.email",
"data.shopify",
"storage.read",
"storage.write"
],
"entrypoint": "index.html",
"icon": "public/icon.png"
}2. Build the Campaign Brief Form
Create src/CampaignForm.tsx:
interface CampaignBrief {
productName: string;
productUrl: string;
tone: 'professional' | 'playful' | 'urgent' | 'friendly';
goal: 'announce' | 'promote' | 'reengagement' | 'welcome';
discount: string;
audience: string;
}
const TONE_OPTIONS = [
{ value: 'professional', label: '🎩 Professional' },
{ value: 'playful', label: '🎉 Playful' },
{ value: 'urgent', label: '⚡ Urgent' },
{ value: 'friendly', label: '😊 Friendly' },
];
const GOAL_OPTIONS = [
{ value: 'announce', label: 'Product Announcement' },
{ value: 'promote', label: 'Promotional / Sale' },
{ value: 'reengagement', label: 'Win-Back / Re-engagement' },
{ value: 'welcome', label: 'Welcome Series' },
];
interface Props {
onGenerate: (brief: CampaignBrief) => void;
loading: boolean;
segments: { id: string; name: string }[];
}
export function CampaignForm({ onGenerate, loading, segments }: Props) {
const [brief, setBrief] = useState<CampaignBrief>({
productName: '',
productUrl: '',
tone: 'friendly',
goal: 'promote',
discount: '',
audience: '',
});
const update = (field: keyof CampaignBrief) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setBrief((b) => ({ ...b, [field]: e.target.value }));
return (
<form
onSubmit={(e) => { e.preventDefault(); onGenerate(brief); }}
className="campaign-form"
>
<h2>Campaign Brief</h2>
<label>
Product / Collection Name *
<input value={brief.productName} onChange={update('productName')} required />
</label>
<label>
Product URL
<input
type="url"
value={brief.productUrl}
onChange={update('productUrl')}
placeholder="https://yourstore.com/products/..."
/>
</label>
<div className="two-col">
<label>
Tone
<select value={brief.tone} onChange={update('tone')}>
{TONE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label>
Campaign Goal
<select value={brief.goal} onChange={update('goal')}>
{GOAL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
</div>
<label>
Discount / Offer (optional)
<input
value={brief.discount}
onChange={update('discount')}
placeholder="e.g. 20% off, free shipping"
/>
</label>
<label>
Target Segment
<select value={brief.audience} onChange={update('audience')}>
<option value="">All subscribers</option>
{segments.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<button type="submit" disabled={loading || !brief.productName}>
{loading ? 'Generating...' : '✨ Generate Campaign'}
</button>
</form>
);
}3. AI Generation with Streaming
Create src/useEmailGenerator.ts:
import { useState, useCallback } from 'react';
import { reeve } from '@reeve/app-sdk';
export interface GeneratedCampaign {
subjectLine: string;
previewText: string;
body: string;
}
export function useEmailGenerator() {
const [generating, setGenerating] = useState(false);
const [output, setOutput] = useState('');
const [campaign, setCampaign] = useState<GeneratedCampaign | null>(null);
const generate = useCallback(async (brief: {
productName: string;
productUrl: string;
tone: string;
goal: string;
discount: string;
}) => {
setGenerating(true);
setOutput('');
setCampaign(null);
const prompt = `
Write a complete email campaign for:
- Product/Collection: ${brief.productName}
${brief.productUrl ? `- Product URL: ${brief.productUrl}` : ''}
- Campaign goal: ${brief.goal}
- Tone: ${brief.tone}
${brief.discount ? `- Offer: ${brief.discount}` : ''}
Output EXACTLY in this format:
SUBJECT: [subject line, max 50 chars]
PREVIEW: [preview text, max 90 chars]
---
[Full email body in HTML — use <p>, <strong>, <a> tags only]
`.trim();
let fullText = '';
await reeve.ai.stream(
{
prompt,
systemPrompt: `You are an expert ecommerce email copywriter. You write high-converting,
brand-appropriate email campaigns. Keep subject lines punchy.
Email body should be scannable — short paragraphs, clear CTA button.
Output valid HTML for the email body section only (no <html>/<head>/<body> wrapper).`,
intent: 'generation',
maxTokens: 800,
},
(chunk) => {
fullText += chunk.delta;
setOutput(fullText);
if (chunk.done) {
// Parse the structured output
const lines = fullText.split('\n');
const subjectLine = lines.find(l => l.startsWith('SUBJECT:'))
?.replace('SUBJECT:', '').trim() ?? '';
const previewText = lines.find(l => l.startsWith('PREVIEW:'))
?.replace('PREVIEW:', '').trim() ?? '';
const bodyStart = fullText.indexOf('---\n');
const body = bodyStart >= 0 ? fullText.slice(bodyStart + 4).trim() : '';
setCampaign({ subjectLine, previewText, body });
setGenerating(false);
}
}
);
}, []);
return { generate, generating, output, campaign };
}4. Campaign Preview Component
Create src/CampaignPreview.tsx:
interface Props {
subjectLine: string;
previewText: string;
body: string;
onSave: () => void;
onRegenerate: () => void;
saving: boolean;
}
export function CampaignPreview({
subjectLine, previewText, body, onSave, onRegenerate, saving
}: Props) {
return (
<div className="preview-pane">
<div className="preview-header">
<div>
<div className="preview-label">Subject line</div>
<div className="preview-subject">{subjectLine}</div>
<div className="preview-label" style={{ marginTop: 8 }}>Preview text</div>
<div className="preview-preview-text">{previewText}</div>
</div>
<div className="preview-actions">
<button className="secondary" onClick={onRegenerate}>
↺ Regenerate
</button>
<button onClick={onSave} disabled={saving}>
{saving ? 'Saving...' : '💾 Save Draft'}
</button>
</div>
</div>
<div className="email-preview">
<div className="email-chrome">
<div className="email-chrome-bar">
<span>From: Your Store <hello@yourstore.com></span>
<span>Subject: {subjectLine}</span>
</div>
<div
className="email-body"
dangerouslySetInnerHTML={{ __html: body }}
/>
</div>
</div>
</div>
);
}dangerouslySetInnerHTML is used here for the email preview because the AI generates HTML email content. In production, consider sanitizing with DOMPurify before rendering.
5. Draft Management with Cloud Storage
Create src/useDrafts.ts:
import { useState, useEffect, useCallback } from 'react';
import { reeve } from '@reeve/app-sdk';
import type { GeneratedCampaign } from './useEmailGenerator';
export interface CampaignDraft {
id: string;
name: string;
brief: Record<string, string>;
campaign: GeneratedCampaign;
savedAt: string;
}
export function useDrafts() {
const [drafts, setDrafts] = useState<CampaignDraft[]>([]);
const [saving, setSaving] = useState(false);
const loadDrafts = useCallback(async () => {
const result = await reeve.storage.list<CampaignDraft>('email-drafts', {
limit: 20,
orderBy: '_meta.updatedAt',
orderDir: 'desc',
});
setDrafts(result.items as CampaignDraft[]);
}, []);
useEffect(() => {
loadDrafts();
}, [loadDrafts]);
const saveDraft = useCallback(async (
brief: Record<string, string>,
campaign: GeneratedCampaign
) => {
setSaving(true);
try {
const id = `draft-${Date.now()}`;
const draft: CampaignDraft = {
id,
name: `${brief.productName} — ${new Date().toLocaleDateString()}`,
brief,
campaign,
savedAt: new Date().toISOString(),
};
await reeve.storage.set('email-drafts', id, draft);
await loadDrafts();
return id;
} finally {
setSaving(false);
}
}, [loadDrafts]);
const deleteDraft = useCallback(async (id: string) => {
await reeve.storage.delete('email-drafts', id);
setDrafts((d) => d.filter((draft) => draft.id !== id));
}, []);
return { drafts, saving, saveDraft, deleteDraft, loadDrafts };
}6. Wire It All Together
Replace src/App.tsx:
import { useEffect, useState } from 'react';
import { reeve } from '@reeve/app-sdk';
import { CampaignForm } from './CampaignForm';
import { CampaignPreview } from './CampaignPreview';
import { DraftList } from './DraftList';
import { useEmailGenerator } from './useEmailGenerator';
import { useDrafts } from './useDrafts';
import './App.css';
export default function App() {
const [ready, setReady] = useState(false);
const [segments, setSegments] = useState<{ id: string; name: string }[]>([]);
const [currentBrief, setCurrentBrief] = useState<Record<string, string> | null>(null);
const [view, setView] = useState<'compose' | 'drafts'>('compose');
const { generate, generating, campaign } = useEmailGenerator();
const { drafts, saving, saveDraft, deleteDraft } = useDrafts();
useEffect(() => {
async function init() {
await reeve.init();
// Load Klaviyo segments for the audience picker
try {
const result = await reeve.data.query({
source: 'email',
type: 'segments',
limit: 50,
fields: ['id', 'name'],
});
setSegments(result.data as { id: string; name: string }[]);
} catch {
// Klaviyo not connected — segments picker will show only "All"
}
setReady(true);
}
init();
}, []);
if (!ready) return <div className="loading">Connecting to Reeve...</div>;
return (
<div className="app-layout">
<nav className="app-nav">
<span className="app-logo">✉️ Campaign Builder</span>
<div className="nav-tabs">
<button
className={view === 'compose' ? 'active' : ''}
onClick={() => setView('compose')}
>
Compose
</button>
<button
className={view === 'drafts' ? 'active' : ''}
onClick={() => setView('drafts')}
>
Drafts ({drafts.length})
</button>
</div>
</nav>
{view === 'compose' && (
<div className="compose-layout">
<CampaignForm
segments={segments}
loading={generating}
onGenerate={(brief) => {
setCurrentBrief(brief as Record<string, string>);
generate(brief);
}}
/>
{(generating || campaign) && (
<div className="output-pane">
{generating && !campaign && (
<div className="streaming-output">
<div className="typing-indicator">✨ Generating...</div>
</div>
)}
{campaign && (
<CampaignPreview
{...campaign}
saving={saving}
onSave={() => saveDraft(currentBrief!, campaign)}
onRegenerate={() => generate(currentBrief as any)}
/>
)}
</div>
)}
</div>
)}
{view === 'drafts' && (
<DraftList
drafts={drafts}
onDelete={deleteDraft}
onLoad={(draft) => {
setCurrentBrief(draft.brief);
setView('compose');
}}
/>
)}
</div>
);
}7. The Drafts List
Create src/DraftList.tsx:
import type { CampaignDraft } from './useDrafts';
interface Props {
drafts: CampaignDraft[];
onDelete: (id: string) => void;
onLoad: (draft: CampaignDraft) => void;
}
export function DraftList({ drafts, onDelete, onLoad }: Props) {
if (drafts.length === 0) {
return (
<div className="empty-state">
<p>No saved drafts yet.</p>
<p>Generate a campaign and click "Save Draft".</p>
</div>
);
}
return (
<div className="draft-list">
<h2>Saved Drafts</h2>
{drafts.map((draft) => (
<div key={draft.id} className="draft-card">
<div className="draft-info">
<div className="draft-name">{draft.name}</div>
<div className="draft-subject">{draft.campaign.subjectLine}</div>
<div className="draft-date">
{new Date(draft.savedAt).toLocaleString()}
</div>
</div>
<div className="draft-actions">
<button className="secondary" onClick={() => onLoad(draft)}>
Open
</button>
<button
className="danger"
onClick={() => {
if (confirm('Delete this draft?')) onDelete(draft.id);
}}
>
Delete
</button>
</div>
</div>
))}
</div>
);
}8. Run It
npm run devInstall in the Cockpit and test:
- Fill in the campaign brief — enter a product name, choose a tone and goal
- Click Generate Campaign — watch the email stream in live
- Review the subject line and email preview
- Click Save Draft — it persists to cloud storage
- Switch to the Drafts tab — your campaign is there
- Reload the Cockpit — drafts survive (stored in cloud, not localStorage)
What's Next
- Send via Klaviyo — integrate with the Klaviyo API to actually send the campaign
- A/B subject lines — generate 3 subject options and let the merchant pick
- Saved templates — store successful campaigns as reusable templates via
reeve.storage - Segment analytics — show segment size from
reeve.data.querybefore generating