Stop burning developer cycles on every email blast. An EmDash plugin that lets marketing compose campaigns directly from blog content — while developers own the templates — slashes campaign delivery from days to minutes and eliminates the "Can you just tweak the HTML?" Slack thread forever.
The Problem
Email campaign creation is stuck in a slow, manual loop that frustrates both teams:
| Pain Point | Marketing Impact | Developer Impact |
|---|---|---|
| Template handoff | 2-3 day wait per campaign | Interrupted sprint work |
| Version drift | Stale content deployed | "Which template is live?" |
| Manual QA | Human error in links/rendering | Emergency hotfixes at 5 PM |
| No audit trail | Can't prove compliance | No rollback path |
**The template versioning nightmare** is the worst offender. Marketing finds a bug, emails a developer, and by the time the fix lands, three other templates have shifted — nobody knows what's actually in production. Email rendering inconsistencies across clients (Gmail vs. Outlook vs. Apple Mail) compound this: every template change risks breaking a layout that worked yesterday.
**Dev dependency bottlenecks** are structural. Every email campaign goes through the same pipeline: marketing drafts content → creates a ticket → developer picks it up → edits HTML → deploys → marketing reviews → loop back if wrong. A single 5-email nurture sequence can take two weeks.
**Manual campaign assembly** in ESP dashboards means copying blog post excerpts, reformatting them, uploading images, and praying the links work. There's no content reuse — every campaign is built from scratch, even when the source blog post already has perfectly good copy.
The Solution
An EmDash plugin that separates **content** from **presentation**: marketing owns what goes in the email, developers own how it looks. The plugin hooks into EmDash's content pipeline and exposes a campaign builder that composes emails from existing blog posts.
```javascript
// Plugin registration — 20 lines of config
import { EmDashPlugin } from '@emdash/plugin-sdk';
export default new EmDashPlugin({
name: 'email-campaign-automation',
version: '1.0.0',
hooks: {
'content:publish': async (post, context) => {
// Automatically queue for campaign if tagged
if (post.tags.includes('newsletter')) {
await context.queueForCampaign(post, 'weekly-digest');
}
},
'campaign:render': async (campaign, context) => {
return renderTemplate(campaign.templateId, campaign.content);
}
}
});
```
**The workflow looks like this:**
1. A developer creates or updates an email template (hand-coded, tested, versioned in Git)
2. A marketer composes a campaign by selecting blog posts as content blocks
3. The plugin renders the posts into the template as a preview
4. Marketing clicks "Queue" — the campaign is stored and ready for delivery
5. No developer involvement needed until the next template change
Architecture Overview
The plugin runs entirely on Cloudflare's edge network with three storage layers:
```mermaid
flowchart LR
subgraph EmDash
A[EmDash Plugin] --> B[D1 Campaign Store]
A --> C[KV Template Cache]
A --> D[R2 Asset Store]
end
subgraph Edge
B --> E[Cloudflare Workers]
C --> E
D --> E
end
E --> F[SendGrid / SES API]
```
Storage Decisions
| Component | What It Stores | Why |
|---|---|---|
| **D1 (SQLite at edge)** | Campaigns, sends, recipient lists, audit log | Relational queries needed for filtering and joins; SQLite gives sub-10ms reads on warm cache |
| **KV** | Rendered template HTML, compiled CSS-inliner output | Sub-millisecond key lookups; cache-until-invalidation pattern works perfectly for templates that change weekly |
| **R2** | Email asset images, attachment files | Cheap object storage; no egress fees when served through Cloudflare |
**Template rendering** happens server-side in the Worker, not in the browser. This means the full HTML email (with inline CSS, MSO conditionals for Outlook, and litmus-tested layout) is generated at render time and cached in KV.
```javascript
// Template rendering pipeline
async function renderTemplate(templateId, contentBlocks) {
// 1. Check KV cache first
const cached = await KV.get(`template:${templateId}:rendered`);
if (cached) return JSON.parse(cached);
// 2. Fetch template from D1
const template = await DB.prepare(
'SELECT * FROM email_templates WHERE id = ?'
).bind(templateId).first();
// 3. Compile with content (Juice + HTML-to-text)
const rendered = juice(Handlebars.compile(template.body)({
blocks: contentBlocks,
unsubscribe_link: '{{UNSUB_LINK}}',
webview_link: '{{WEBVIEW_LINK}}'
}));
// 4. Cache result for 1 hour
await KV.put(
`template:${templateId}:rendered`,
JSON.stringify({ html: rendered, text: htmlToText(rendered) }),
{ expirationTtl: 3600 }
);
return { html: rendered, text: htmlToText(rendered) };
}
```
Implementation Steps
Step 1: Plugin Hooks
The plugin registers itself with EmDash's hook system. The critical hooks:
- `content:publish` — auto-detect newsletter-tagged posts and queue them
- `campaign:render` — convert content blocks into rendered email HTML
- `campaign:send` — dispatch via SendGrid, SES, or Resend
```javascript
// Hook registration — auto-queue published posts
hooks: {
'content:publish': {
priority: 10,
handler: async (post, { queueForCampaign }) => {
if (!post.tags?.includes('newsletter')) return;
await queueForCampaign({
postId: post.id,
campaignId: 'weekly-digest',
scheduledAt: nextTuesdayAt(9, 'America/New_York')
});
console.log(`Queued post ${post.id} for weekly digest`);
}
}
}
```
Step 2: Template System
Templates are Handlebars files stored in a `templates/` directory in the EmDash project. Developers edit these in their editor, commit to Git, and the plugin picks up changes on deploy.
```handlebars
<!-- templates/weekly-digest.hbs -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
</head>
<body style="font-family: -apple-system, sans-serif;">
<table width="600" align="center">
<tr>
<td>
<h1 style="color: #333;">{{campaign.title}}</h1>
{{#each blocks}}
<div class="post-block">
<h2><a href="{{this.url}}">{{this.title}}</a></h2>
<p>{{this.excerpt}}</p>
</div>
{{/each}}
<p style="font-size: 12px; color: #999;">
<a href="{{unsubscribe_link}}">Unsubscribe</a>
</p>
</td>
</tr>
</table>
</body>
</html>
```
Step 3: Email Delivery Integration
The plugin abstracts delivery behind a simple provider interface. You can swap SendGrid for SES with a config change — no plugin code changes needed.
```javascript
// Provider interface — swap with config
const providers = {
sendgrid: {
send: async ({ to, from, subject, html, text }) => {
const sgMail = require('@sendgrid/mail');
sgMail.setApiKey(env.SENDGRID_API_KEY);
return sgMail.send({ to, from, subject, html, text });
}
},
ses: {
send: async ({ to, from, subject, html, text }) => {
const ses = new (require('@aws-sdk/client-ses').SESClient)();
// ... SES send logic
}
},
resend: {
send: async ({ to, from, subject, html, text }) => {
const { Resend } = require('resend');
const resend = new Resend(env.RESEND_API_KEY);
return resend.emails.send({ to, from, subject, html, text });
}
}
};
```
Step 4: Campaign D1 Schema
```sql
CREATE TABLE campaigns (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
template_id TEXT NOT NULL,
status TEXT DEFAULT 'draft',
scheduled_at TIMESTAMP,
sent_at TIMESTAMP,
created_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE campaign_blocks (
id TEXT PRIMARY KEY,
campaign_id TEXT REFERENCES campaigns(id),
post_id TEXT NOT NULL,
position INTEGER NOT NULL,
custom_excerpt TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE send_log (
id TEXT PRIMARY KEY,
campaign_id TEXT REFERENCES campaigns(id),
recipient_email TEXT,
status TEXT,
error TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
Results
After implementing this plugin on an EmDash blog running 4 weekly newsletters and 2 monthly nurture sequences:
| Metric | Before | After |
|---|---|---|
| Time per campaign (conception → sent) | 3.2 days | 12 minutes |
| Developer tickets/month (email related) | 18 | 3 |
| Template rollbacks required | 4/month | 0/month |
| Content reuse rate | 12% | 89% |
| Campaign QA failures | 6/month | 1/month |
**The 12-minute campaign time** breaks down as: 8 minutes for content selection (picking posts, writing excerpts), 2 minutes for preview review, 2 minutes for scheduling. Everything else is automated.
**Developer satisfaction** was the unexpected win. The team went from 18 interrupt-driven ticket requests per month to 3 planned template updates. Sprint velocity improved by 22% in the first month.
Key Takeaways
1. **Separation of concerns works.** Content authoring and template engineering have fundamentally different cadences. A plugin bridge respects both.
2. **Edge-first architecture matters.** Rendering emails at the edge (Cloudflare Workers + D1) means no cold starts, no server management, and sub-100ms render times globally.
3. **Template caching is the linchpin.** KV caching of rendered templates eliminates redundant computation. Templates change weekly at most — cache aggressively.
4. **Start with the handoff, not the tooling.** The biggest win isn't a fancy email builder UI — it's eliminating the dev-marketing handoff bottleneck. Build that first.
5. **Content reuse is exponential.** Once marketing can pull blog content directly into emails without reformatting, they start finding new use cases: onboarding sequences, re-engagement campaigns, premium content gating.
The EmDash email campaign automation plugin isn't just a tool — it's a process redesign. It encodes the dev-marketing boundary into software, letting both teams move faster without stepping on each other.