Ghost handles email differently than most CMS platforms. It ships with newsletter capabilities built-in but requires external SMTP for transactional emails and lacks automated digest features entirely. This post covers setting up both transactional email through Mailgun and building an automated weekly digest system using AWS Lambda and EventBridge.
The weekly digest system we built sends every Monday at 9 AM, compiles posts from the past week into bookmark cards, and integrates with Ghost's native newsletter management. Members can subscribe or unsubscribe through Ghost's interface while automation handles the compilation and sending.
Email Infrastructure Setup
Ghost requires SMTP configuration for transactional emails like password resets and member invitations. We use Mailgun, Ghost's recommended provider, which handles both transactional and bulk newsletter sending.
Mailgun Configuration
First, set up a Mailgun domain:
- Create a Mailgun account and add your domain
- Configure DNS records (SPF, DKIM, MX)
- Verify domain ownership
- Generate SMTP credentials
The DNS records Mailgun requires:
TXT mg.yourdomain.com "v=spf1 include:mailgun.org ~all"
TXT k1._domainkey.mg.yourdomain.com "k=rsa; p=MIGfMA0GCS..."
MX mg.yourdomain.com 10 mxa.mailgun.org
MX mg.yourdomain.com 10 mxb.mailgun.org
Ghost SMTP Configuration
Ghost reads email configuration from environment variables. In our ECS task definition:
environment: {
mail__transport: 'SMTP',
mail__options__service: 'Mailgun',
mail__options__host: 'smtp.mailgun.org',
mail__options__port: '587',
mail__options__secure: 'false',
mail__from: `noreply@mg.${props.domainName}`,
},
secrets: {
mail__options__auth__user: ecs.Secret.fromSecretsManager(mailgunSecret, 'smtpUsername'),
mail__options__auth__pass: ecs.Secret.fromSecretsManager(mailgunSecret, 'smtpPassword'),
}
Store Mailgun credentials in AWS Secrets Manager:
aws secretsmanager create-secret \
--name ghost-mailgun \
--secret-string '{
"apiKey": "your-api-key",
"domain": "mg.yourdomain.com",
"smtpUsername": "postmaster@mg.yourdomain.com",
"smtpPassword": "your-smtp-password"
}'
Ghost Newsletter System
Ghost's newsletter feature sends posts to subscribers when you publish. Each newsletter is a separate entity with its own subscriber list, design settings, and tracking.
Creating a Newsletter
In Ghost Admin, navigate to Settings → Email newsletter → Newsletters:
- Click "Add newsletter"
- Name: "Weekly Digest"
- Description: "A weekly roundup of our latest posts"
- Sender email: noreply@mg.yourdomain.com
- Configure design settings to match your brand
Members can manage subscriptions at yourdomain.com/account/. They can subscribe to specific newsletters without following everything you publish.
Newsletter API Basics
Ghost requires a two-step process to send newsletters programmatically:
// Step 1: Create post as draft
const draft = await adminAPI.posts.add({
title: 'Weekly Digest',
lexical: contentJson, // Lexical format, not HTML
status: 'draft',
email_only: true,
newsletter: 'weekly-digest', // Associate with newsletter
});
// Step 2: Publish and send
await adminAPI.posts.edit(
{
id: draft.id,
status: 'published',
email_only: true,
updated_at: draft.updated_at,
},
{
newsletter: 'weekly-digest',
email_segment: 'all', // Send to all subscribers
},
);
The email_segment parameter controls who receives the newsletter:
'all'- All subscribers'free'- Free members only'paid'- Paid members only'label:vip'- Members with specific label
The Weekly Digest Challenge
Ghost can send newsletters when you publish but cannot automatically compile and send digests on a schedule. Users manually create roundup posts or do without this common newsletter feature. We solved this with AWS Lambda and EventBridge.
The system fetches recent posts every Monday, generates a newsletter in Ghost's Lexical format with bookmark cards, creates it as a Ghost post, and sends to all subscribers. Ghost handles delivery, tracking, and unsubscribe management.
Architecture Overview
graph LR
A[EventBridge Schedule<br/>Monday 9 AM UTC] --> B[Lambda Function]
B --> C[Ghost Content API<br/>Fetch Posts]
C --> D[Generate Lexical JSON<br/>with Bookmark Cards]
D --> E[Ghost Admin API<br/>Create Newsletter]
E --> F[Ghost Sends<br/>to Subscribers]
B --> G[DynamoDB<br/>Track Sent Digests]
B --> H[SNS<br/>Notifications]
The Lambda function runs weekly, queries Ghost for recent posts, and creates a properly formatted newsletter. DynamoDB prevents duplicate sends if the function runs multiple times.
Implementation Components
CDK Infrastructure
The CDK construct creates all necessary resources:
export class GhostWeeklyDigest extends Construct {
constructor(scope: Construct, id: string, props: GhostWeeklyDigestProps) {
super(scope, id);
// DynamoDB table for tracking
this.table = new dynamodb.Table(this, 'TrackingTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
timeToLiveAttribute: 'ttl',
});
// Lambda function
this.function = new NodejsFunction(this, 'Function', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'handler',
entry: 'src/lambda/weekly-digest.ts',
timeout: cdk.Duration.minutes(5),
environment: {
GHOST_URL: props.ghostUrl,
DOMAIN_NAME: props.domainName,
TRACKING_TABLE_NAME: this.table.tableName,
NEWSLETTER_SLUG: props.newsletterSlug || 'weekly-digest',
MIN_POSTS: (props.minPosts || 1).toString(),
SECRET_ARN: props.ghostApiSecret.secretArn,
},
});
// Grant permissions
this.table.grantReadWriteData(this.function);
props.ghostApiSecret.grantRead(this.function);
// EventBridge schedule
this.rule = new events.Rule(this, 'ScheduleRule', {
schedule: events.Schedule.cron({
minute: '0',
hour: '9',
weekDay: 'MON',
}),
});
this.rule.addTarget(new targets.LambdaFunction(this.function));
}
}
Lambda Function Logic
The Lambda queries posts, generates content, and creates the newsletter:
async function handler(event: ScheduledEvent) {
// Check if already sent this week
const weekId = getWeekId();
if (await wasDigestSentThisWeek(weekId)) {
console.log('Digest already sent this week');
return;
}
// Get API keys from Secrets Manager
const { contentKey, adminKey } = await getApiKeys();
// Initialize Ghost APIs
const contentAPI = new GhostContentAPI({
url: GHOST_URL,
key: contentKey,
version: 'v5.0',
});
const adminAPI = new GhostAdminAPI({
url: GHOST_URL,
key: adminKey,
version: 'v5.0',
});
// Fetch recent posts
const posts = await getWeeklyPosts(contentAPI);
if (posts.length < MIN_POSTS) {
console.log(`Not enough posts: ${posts.length} < ${MIN_POSTS}`);
return;
}
// Create and send digest
const digestId = await createDigestNewsletter(adminAPI, posts);
// Record in DynamoDB
await recordDigestSent(weekId, digestId, posts.length);
}
Fetching Weekly Posts
Query posts from the last seven days using Ghost's Content API:
async function getWeeklyPosts(
contentAPI: GhostContentAPI,
): Promise<DigestPost[]> {
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
const posts = await contentAPI.posts.browse({
filter: `published_at:>'${startDate.toISOString()}'`,
limit: 'all',
include: 'tags,authors',
});
return posts;
}
The Lexical Format
Ghost uses Lexical, not HTML, for content. The digest creates bookmark cards that display beautifully in emails and on the web:
function generateDigestLexical(posts: DigestPost[]): string {
const children: any[] = [];
// Add header
children.push({
children: [
{
detail: 0,
format: 1,
mode: 'normal',
style: '',
text: `This Week on ${DOMAIN_NAME}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h1',
});
// Add bookmark card for each post
posts.forEach((post) => {
children.push({
type: 'bookmark',
version: 1,
url: post.url,
metadata: {
icon: 'https://static.ghost.org/v5.0.0/images/link-icon.svg',
title: post.title,
description: post.excerpt || '',
author: post.authors?.[0]?.name || '',
publisher: DOMAIN_NAME,
thumbnail: post.feature_image || '',
},
caption: '',
});
// Add paragraph separator
children.push({
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
});
});
return JSON.stringify({
root: {
children: children,
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
});
}
Bookmark cards automatically fetch metadata and display rich previews. Ghost handles the rendering for both email and web views.
Creating and Sending the Newsletter
Create the newsletter post with proper association:
async function createDigestNewsletter(
adminAPI: GhostAdminAPI,
posts: DigestPost[],
): Promise<string> {
const lexicalContent = generateDigestLexical(posts);
const title = `Weekly Digest - ${formatWeekRange()}`;
// Create draft with newsletter association
const draft = await adminAPI.posts.add({
title,
lexical: lexicalContent,
status: 'draft',
email_only: true,
newsletter: NEWSLETTER_SLUG,
});
// Publish and send to all subscribers
await adminAPI.posts.edit(
{
id: draft.id,
status: 'published',
email_only: true,
updated_at: draft.updated_at,
},
{
email_segment: 'all',
newsletter: NEWSLETTER_SLUG,
},
);
return draft.id;
}
The newsletter field in post creation ensures Ghost tracks the email in newsletter statistics. Without it, emails send but show "0 Delivered" in the dashboard.
Preventing Duplicate Sends
DynamoDB tracks sent digests to prevent duplicates:
async function wasDigestSentThisWeek(weekId: string): Promise<boolean> {
const response = await dynamoClient.send(
new GetItemCommand({
TableName: TRACKING_TABLE_NAME,
Key: {
PK: { S: `DIGEST#${weekId}` },
SK: { S: 'SENT' },
},
}),
);
return !!response.Item;
}
async function recordDigestSent(
weekId: string,
digestId: string,
postCount: number,
) {
await dynamoClient.send(
new PutItemCommand({
TableName: TRACKING_TABLE_NAME,
Item: {
PK: { S: `DIGEST#${weekId}` },
SK: { S: 'SENT' },
digestId: { S: digestId },
postCount: { N: postCount.toString() },
sentAt: { S: new Date().toISOString() },
ttl: {
N: Math.floor(Date.now() / 1000 + 90 * 24 * 60 * 60).toString(),
},
},
}),
);
}
Records expire after 90 days to keep the table clean.
Configuration
Set these environment variables in your .env:
# Ghost API Keys
GHOST_ADMIN_API_KEY=your-admin-key:secret
GHOST_CONTENT_API_KEY=your-content-key
# Email Configuration
MAILGUN_API_KEY=your-mailgun-api-key
MAILGUN_DOMAIN=mg.yourdomain.com
MAILGUN_SMTP_PASSWORD=your-smtp-password
# Weekly Digest
ENABLE_WEEKLY_DIGEST=true
ALERT_EMAIL=alerts@yourdomain.com
Get Ghost API keys from Settings → Integrations in Ghost Admin. Create a custom integration for the weekly digest system.
Deployment and Testing
Deploy the infrastructure:
pnpm run build
pnpm run cdk deploy GhostStack
# Outputs
WeeklyDigestFunction = GhostStack-WeeklyDigestFunction-ABC123
WeeklyDigestTable = GhostStack-WeeklyDigestTrackingTable-XYZ789
WeeklyDigestSchedule = cron(0 9 ? * MON *)
Test without waiting for Monday:
# Invoke the Lambda directly
aws lambda invoke \
--function-name GhostStack-WeeklyDigestFunction-ABC123 \
--payload '{"detail-type": "Scheduled Event", "source": "aws.events"}' \
response.json
# Check logs
aws logs tail /aws/lambda/GhostStack-WeeklyDigestFunction-ABC123 --follow
Monitoring
CloudWatch tracks execution and errors:
// Add CloudWatch dashboard
const dashboard = new cloudwatch.Dashboard(this, 'Dashboard', {
dashboardName: 'ghost-weekly-digest',
});
dashboard.addWidgets(
new cloudwatch.GraphWidget({
title: 'Lambda Executions',
left: [this.function.metricInvocations()],
right: [this.function.metricErrors()],
}),
new cloudwatch.GraphWidget({
title: 'Lambda Duration',
left: [this.function.metricDuration()],
}),
);
SNS sends notifications for errors or skipped weeks:
const topic = new sns.Topic(this, 'NotificationTopic');
topic.addSubscription(
new snsSubscriptions.EmailSubscription(props.notificationEmail),
);
// In Lambda, send notifications
await snsClient.send(
new PublishCommand({
TopicArn: SNS_TOPIC_ARN,
Subject: 'Weekly Digest Sent',
Message: `Digest sent with ${posts.length} posts. ID: ${digestId}`,
}),
);
Next Steps
The system provides a foundation for any scheduled newsletter automation. Consider these enhancements:
- Multiple digest types (daily, monthly, category-specific)
- Custom filters for premium content digests
- A/B testing different subject lines
- Click tracking and engagement analytics
- Member preference center for digest customization
Ghost's newsletter infrastructure handles the complex parts - delivery, tracking, and unsubscribe management. Our automation adds what Ghost lacks - scheduled compilation and sending. Together, they provide a complete newsletter platform that rivals dedicated email services.