Table of Contents
Documentation
ContentDrip Docs
Everything you need to set up, customize, and deploy your own email drip courses.
Introduction
ContentDrip is an open-source Next.js template for building automated email drip courses. You clone the repository, write your course content in markdown, define a delivery schedule, and deploy. ContentDrip handles everything else: subscriber management, scheduled delivery with timezone support, companion web pages, custom email branding, pause/resume, and one-click unsubscribe.
The entire content model is based on Content Packs — self-contained folders of markdown files with a config and an email template. Each pack is an independent course that can have its own branding, schedule, and subscriber base. You can run one pack or many from the same deployment.
ContentDrip is built for developers and creators who want full control over their email courses without relying on third-party platforms. There's no vendor lock-in, no monthly fees, and no limits on subscribers or emails beyond what your infrastructure supports.
Quick Start
Get a local development instance running in five steps.
1. Clone the repository
git clone https://github.com/petergombos/content-drip.git
cd content-drip2. Install dependencies
npm install3. Configure environment variables
Create a .env file in the project root. You need a Turso database, a Postmark account for sending emails, and a secret for cron authentication.
# Database — Turso (LibSQL)
TURSO_DATABASE_URL=libsql://your-db-name.turso.io
TURSO_AUTH_TOKEN=your-turso-auth-token
# Email — Postmark
POSTMARK_SERVER_TOKEN=your-postmark-server-token
POSTMARK_MESSAGE_STREAM=content-emails
MAIL_FROM=you@yourdomain.com
# Cron — authenticates the /api/cron endpoint
CRON_SECRET=generate-another-random-string
# Optional — speed up delivery for local testing
# Makes 1 day = 10 minutes (24*60 / 144 = 10)
# DRIP_TIME_SCALE=1444. Push the database schema
ContentDrip uses Drizzle ORM with SQLite (via Turso). Push the schema to create the required tables:
npx drizzle-kit push5. Start the dev server
npm run devVisit http://localhost:3000/example to see the example content pack landing page. The subscribe form is fully functional — try subscribing with a test email to see the full flow.
Project Structure
ContentDrip is a standard Next.js App Router project. Here are the key directories:
src/
├── app/ # Next.js App Router
│ ├── page.tsx # Marketing homepage
│ ├── example/page.tsx # Example content pack landing
│ ├── docs/page.tsx # This documentation page
│ ├── manage/ # Subscription management
│ ├── confirm/[token]/ # Email confirmation handler
│ ├── p/[packKey]/[slug]/ # Companion pages
│ └── api/
│ ├── subscribe/ # Subscription creation
│ ├── cron/ # Scheduled email delivery
│ ├── pause/ # Pause/stop from email links
│ └── stop/ # Unsubscribe from email links
├── content-packs/ # Your courses live here
│ ├── registry.ts # ContentPack type definitions
│ ├── index.ts # Barrel file (imports all packs)
│ └── dummy/ # Example pack (delete or modify)
├── components/ # Shared UI components
├── domains/ # Business logic
│ └── subscriptions/
│ ├── actions/ # Server actions
│ ├── services/ # SchedulerService, etc.
│ └── repos/ # Database queries
├── db/ # Drizzle schema and config
└── lib/ # Shared utilitiesContent Packs
A Content Pack is the core abstraction in ContentDrip. It's a self-contained course: a collection of markdown emails and optional companion pages, bundled with metadata and an email template. Every pack implements the ContentPack interface:
interface ContentPack {
key: string; // Unique identifier, used in URLs and DB
name: string; // Display name shown to subscribers
description: string; // Short description for landing pages
steps: ContentStep[]; // Ordered list of lessons
EmailShell?: Component; // Optional custom email template
}
interface ContentStep {
slug: string; // URL-safe identifier (e.g. "day-1")
emailFile: string; // Markdown file in emails/ directory
pageFile?: string; // Markdown file in pages/ (defaults to emailFile)
}The key is used throughout the system: in database records, URL paths (/p/your-key/day-1), email tags, and the pack registry. Choose something URL-safe and descriptive.
The steps array defines the delivery order. Step 0 is the welcome email (sent immediately on confirmation). Subsequent steps are sent one per day at the subscriber's chosen time. Each step maps to a markdown file in your pack's emails/ directory.
The optional EmailShell is a React Email component that wraps every outgoing email in your pack's custom branding — header, footer, typography, colors. If omitted, a default shell is used.
Subscriber Flow
Every subscription follows a predictable lifecycle managed by status transitions in the database:
PENDING_CONFIRM ──→ ACTIVE ──→ COMPLETED
│
├──→ PAUSED ──→ ACTIVE (resume)
│
└──→ STOPPED (unsubscribed)Subscribe: A visitor fills in the form with their email and preferred delivery time. The timezone is auto-detected from the browser. A subscription record is created with status PENDING_CONFIRM and a signed confirmation token is emailed.
Confirm: The subscriber clicks the confirmation link. The token hash is verified against the database. If valid, the subscription status changes to ACTIVE and the welcome email (step 0) is sent immediately.
Drip Delivery: A cron job hits /api/cron every minute. The scheduler evaluates each active subscription: is the next step due based on the subscriber's chosen time and timezone? If yes, it loads the markdown, replaces placeholder variables with signed URLs, renders through the EmailShell, and sends via Postmark.
Pause / Resume: Subscribers can pause delivery at any time via a signed link in any email. The status changes to PAUSED. The scheduler skips paused subscriptions. When resumed (via the manage page), delivery continues from the next unsent step.
Completion: After the final step is sent, the subscription status changes to COMPLETED. No further emails are sent.
Unsubscribe: The one-click unsubscribe link sets the status to STOPPED. This is immediate and irreversible (the subscriber can re-subscribe manually if they want to start over).
All action links (confirm, manage, pause, stop) use cryptographically signed, single-use tokens. No passwords or sessions are required. Tokens are hashed with SHA-256 before storage and verified on use.
Scheduling & Delivery
Email delivery is driven by a cron job that calls the /api/cron endpoint. The endpoint authenticates via the CRON_SECRET environment variable and delegates to the SchedulerService.
Normal Mode
In production, the scheduler evaluates cron expressions. When a subscriber signs up and chooses "8am", a cron expression like 0 8 * * * is stored along with their timezone (e.g., America/New_York). Each minute, the scheduler parses the expression in the subscriber's timezone using cron-parser and checks if the current time falls within the last 60 seconds of the scheduled time.
Fast-Test Mode
For local development and testing, you don't want to wait 24 hours between emails. Set DRIP_TIME_SCALE to speed up delivery:
# 1 day becomes 10 minutes (144x speed)
DRIP_TIME_SCALE=144
# 1 day becomes 1 minute (1440x speed)
DRIP_TIME_SCALE=1440
In fast-test mode, the scheduler ignores cron expressions entirely and instead checks how many minutes have elapsed since the last email was sent. If enough time has passed (based on the scale factor), the next step is sent.
Idempotency
Every email sent is logged in the sendLog table with the subscription ID, pack key, and step slug. Before sending, the scheduler checks if the current step has already been logged. This prevents duplicate deliveries if the cron job runs multiple times within the same minute or if there are retries.
Retry Logic
The cron endpoint implements automatic retries for Turso database capacity errors (503). It will retry up to 3 times with exponential backoff (500ms, 1500ms). Other errors are returned immediately.
Email Templates
ContentDrip uses React Email for rendering email templates. Each content pack defines an EmailShell component that wraps the rendered markdown content. The rendering pipeline is:
1. Load markdown file from emails/{step}.md
2. Parse YAML frontmatter → { subject, preview }
3. Replace placeholders → {{companionUrl}}, {{manageUrl}}, etc.
4. Convert markdown → HTML (via gray-matter + markdown parser)
5. Wrap HTML in pack's EmailShell (React Email component)
6. Render React Email → final HTML string
7. Send via Postmark with subject, preview, and tagThe EmailShell component receives PackEmailShellProps:
interface PackEmailShellProps {
preview?: string; // Email preview text (inbox snippet)
title: string; // Email subject line
children: React.ReactNode; // Rendered markdown HTML content
footer?: {
unsubscribeUrl?: string; // One-click unsubscribe link
manageUrl?: string; // Manage preferences link
pauseUrl?: string; // Pause delivery link
};
}Use @react-email/components (Html, Head, Body, Container, Section, Text, Link, Img, etc.) to build your shell. These components generate cross-client-compatible HTML. See the example pack's email-shell.tsx for a complete working implementation.
Creating a Content Pack
Follow these steps to create a new content pack from scratch. You can also copy and modify the example dummy pack as a starting point.
1. Create the directory structure
mkdir -p src/content-packs/my-course/emails
mkdir -p src/content-packs/my-course/pages2. Define your pack configuration
import { registerPack, type ContentPack } from "../registry";
import { MyEmailShell } from "./email-shell";
const pack: ContentPack = {
key: "my-course",
name: "My Email Course",
description: "A 5-day course on building better habits.",
steps: [
{ slug: "welcome", emailFile: "welcome.md" },
{ slug: "day-1", emailFile: "day-1.md" },
{ slug: "day-2", emailFile: "day-2.md" },
{ slug: "day-3", emailFile: "day-3.md" },
{ slug: "day-4", emailFile: "day-4.md" },
{ slug: "day-5", emailFile: "day-5.md" },
],
EmailShell: MyEmailShell,
};
registerPack(pack);3. Write your email content
Create a markdown file for each step in emails/. Each file needs YAML frontmatter with a subject and optional preview:
---
subject: Welcome to My Email Course
preview: Your journey starts tomorrow morning
---
Hi there!
Welcome to **My Email Course**. Over the next five days, you'll receive
one lesson each morning at the time you chose.
## What to Expect
- **Day 1:** Topic one
- **Day 2:** Topic two
- **Day 3:** Topic three
[Read this lesson online →]({{companionUrl}})4. Create companion pages (optional)
Companion pages are web-readable versions of each email. Create markdown files in pages/ with the same slugs. If you omit a page file, the email markdown is used as fallback. Pages can have different content (e.g., larger images, extra context).
5. Build an email template
Create an email-shell.tsx using React Email components. See the Custom Email Branding section below for details.
6. Register the pack
Add an import to the barrel file so ContentDrip discovers your pack:
import "@/content-packs/dummy/pack"; // Example pack
import "@/content-packs/my-course/pack"; // Your new packCustom Email Branding
The EmailShell is a React Email component that wraps every outgoing email for a pack. It controls the header, footer, typography, colors, and layout. Here's a minimal example:
import {
Html, Head, Body, Container, Section,
Text, Link, Hr, Preview,
} from "@react-email/components";
import type { PackEmailShellProps } from "../registry";
export function MyEmailShell(props: PackEmailShellProps) {
return (
<Html>
<Head />
{props.preview && <Preview>{props.preview}</Preview>}
<Body style={{ background: "#f9f9f9", fontFamily: "Georgia, serif" }}>
<Container style={{ maxWidth: 560, margin: "0 auto", padding: 40 }}>
{/* Header */}
<Text style={{ fontSize: 12, color: "#999", letterSpacing: 2 }}>
MY EMAIL COURSE
</Text>
{/* Title */}
<Text style={{ fontSize: 24, fontWeight: "bold" }}>
{props.title}
</Text>
<Hr />
{/* Content — rendered markdown HTML goes here */}
{props.children}
<Hr />
{/* Footer with action links */}
<Section style={{ fontSize: 12, color: "#999" }}>
{props.footer?.manageUrl && (
<Link href={props.footer.manageUrl}>Manage preferences</Link>
)}
{" · "}
{props.footer?.pauseUrl && (
<Link href={props.footer.pauseUrl}>Pause delivery</Link>
)}
{" · "}
{props.footer?.unsubscribeUrl && (
<Link href={props.footer.unsubscribeUrl}>Unsubscribe</Link>
)}
</Section>
</Container>
</Body>
</Html>
);
}The children prop contains the rendered HTML from your markdown email. It's injected as a React node, so it flows naturally within your template. Use inline styles for reliable cross-client rendering — most email clients strip <style> tags, though some (like Gmail) do support them.
Refer to the src/content-packs/dummy/email-shell.tsx file in the repository for a full production example with images, branded colors, and responsive layout.
Markdown & Frontmatter
Email content is written in standard markdown with YAML frontmatter. ContentDrip uses gray-matter to parse the frontmatter and a markdown parser to convert the body to HTML.
Frontmatter fields
| Field | Required | Description |
|---|---|---|
| subject | Yes | Email subject line |
| preview | No | Preview text shown in email clients (inbox snippet) |
Supported syntax
Standard markdown: headings, bold, italic, links, images, lists, blockquotes, code blocks, and horizontal rules. Images are rendered as <img> tags in emails — use full absolute URLs for images (relative paths won't work in email clients).
Placeholder Variables
Use these placeholders in your markdown content. They're replaced at send time with signed, subscriber-specific URLs:
| Variable | Description |
|---|---|
| {{companionUrl}} | Web-readable version of this specific lesson. Points to /p/{packKey}/{stepSlug}. |
| {{manageUrl}} | Manage subscription preferences page. Uses a signed single-use token for authentication. |
| {{pauseUrl}} | One-click pause delivery. Hits /api/pause with a signed token. Subscriber can resume later. |
| {{stopUrl}} | One-click unsubscribe. Hits /api/stop with a signed token. Immediately stops all delivery. |
Example usage in markdown:
Can't read this email? [View it online]({{companionUrl}}).
---
[Manage preferences]({{manageUrl}}) · [Pause delivery]({{pauseUrl}}) · [Unsubscribe]({{stopUrl}})Multiple Packs
ContentDrip supports running multiple content packs from a single deployment. Each pack is fully independent — it has its own key, subscriber base, email branding, and delivery schedule.
To add a new pack, create a new directory under src/content-packs/, define the pack and register it, then add the import to the barrel file. The subscribe form automatically picks up registered packs. Companion pages are namespaced by pack key (/p/pack-a/day-1 vs /p/pack-b/day-1).
Environment Variables
| Variable | Req | Description |
|---|---|---|
| TURSO_DATABASE_URL | Yes | LibSQL / Turso database connection URL |
| TURSO_AUTH_TOKEN | Yes | Database authentication token |
| POSTMARK_SERVER_TOKEN | Yes | Postmark API key for sending emails |
| POSTMARK_MESSAGE_STREAM | Yes | Postmark message stream name (e.g. content-emails) |
| MAIL_FROM | Yes | Sender email address (must be verified in Postmark) |
| CRON_SECRET | Yes | Bearer token to authenticate /api/cron requests |
| DRIP_TIME_SCALE | No | Speed multiplier for testing. 144 = 1 day per 10 min. 1440 = 1 day per 1 min. |
| VERCEL_ENV | No | Set automatically by Vercel. Enables preview-mode cron auth. |
Database Schema
ContentDrip uses three tables managed by Drizzle ORM with SQLite (Turso). All IDs are text (UUIDs). Timestamps are stored as milliseconds.
subscriptions
Stores every subscriber and their delivery state.
id text PRIMARY KEY
email text subscriber email address
packKey text which content pack they subscribed to
timezone text IANA timezone (e.g. "Europe/London")
cronExpression text delivery schedule (e.g. "0 8 * * *")
status text PENDING_CONFIRM | ACTIVE | PAUSED | STOPPED | COMPLETED
currentStepIndex integer which step to send next (starts at 0)
createdAt integer creation timestamp (ms)
updatedAt integer last update timestamp (ms)tokens
Signed tokens for confirm, manage, pause, and stop actions. Tokens are hashed (SHA-256) before storage and are single-use.
id text PRIMARY KEY
subscriptionId text FK → subscriptions (cascade delete)
tokenHash text SHA-256 hash of the token
tokenType text CONFIRM | MANAGE | PAUSE | STOP
expiresAt integer expiry timestamp (ms)
usedAt integer when the token was consumed (nullable)
createdAt integer creation timestamp (ms)sendLog
Audit trail of every email sent. Used for idempotency checks and debugging delivery issues.
id text PRIMARY KEY
subscriptionId text FK → subscriptions (cascade delete)
packKey text content pack key
stepSlug text which step was sent (e.g. "day-1")
provider text email provider used (e.g. "postmark")
providerMessageId text provider's message ID (nullable)
status text SUCCESS | FAILED
sentAt integer when the email was sent (ms)
error text error message if failed (nullable)
createdAt integer creation timestamp (ms)API Routes
POST /api/subscribe
Creates a new subscription. Called by the subscribe form via server action. Requires CRON_SECRET as a Bearer token in the Authorization header.
{
"email": "user@example.com",
"packKey": "my-course",
"timezone": "America/New_York",
"cronExpression": "0 8 * * *"
}GET /api/cron
Triggers the email delivery scheduler. Should be called every minute by a cron job. Authenticated via Authorization: Bearer {CRON_SECRET} header. In non-production Vercel environments, also accepts ?secret={CRON_SECRET} as a query parameter.
{
"success": true,
"sent": 3, // emails sent this run
"errors": 0, // send failures
"attempt": 1, // retry attempt number
"timestamp": "2025-01-15T08:00:00.000Z"
}GET /api/pause
Pauses or stops a subscription via signed token. Parameters: ?token=...&id=...&action=pause|stop. Redirects to /example?paused=true or /example?unsubscribed=true.
GET /api/stop
Unsubscribes via signed token. Parameters: ?token=...&id=.... Redirects to /example?unsubscribed=true.
GET /confirm/[token]
Confirmation page. The token is the raw (unhashed) confirmation token from the email link. The page hashes it, looks up the matching token record, and activates the subscription.
Deployment
Vercel (recommended)
Push your repository to GitHub and import it in Vercel. Vercel auto-detects the Next.js project. Set all required environment variables in the Vercel dashboard. Add a vercel.json to configure the cron job:
{
"crons": [
{
"path": "/api/cron",
"schedule": "* * * * *"
}
]
}This calls /api/cron every minute. Vercel automatically injects the CRON_SECRET as a Bearer token in the Authorization header for cron requests.
Other hosts
ContentDrip runs on any Node.js host that supports Next.js. You'll need to set up your own cron job (e.g., via crontab, AWS EventBridge, or a service like cron-job.org) to call /api/cron every minute with the correct Authorization header.
# crontab entry
* * * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://yourdomain.com/api/cronReady to build?