Ghost CMS provides excellent newsletter capabilities, but lacks a built-in way to automatically send weekly digest emails summarizing recent posts. Building this feature requires understanding Ghost's Lexical editor format, coordinating between Ghost's Content and Admin APIs, and preventing duplicate sends. The solution uses EventBridge for scheduling, Lambda for processing, and DynamoDB for deduplication tracking.
The system automatically collects posts from the past week, generates a newsletter in Ghost's native format with rich preview cards, and sends it to all subscribers. Each post appears as a bookmark card with title, excerpt, and featured image, providing an engaging summary for readers.
Architecture Overview
The weekly digest system runs on a schedule, fetching recent posts and creating a new newsletter post in Ghost. DynamoDB tracks which digests have been sent to prevent duplicates if the Lambda retries.
graph TB
subgraph EventBridge
Schedule[Weekly Schedule<br/>Mondays 9 AM]
end
subgraph Lambda
Handler[Digest Handler]
Content[Content API<br/>Read Posts]
Admin[Admin API<br/>Create Newsletter]
Lexical[Lexical Generator<br/>Bookmark Cards]
end
subgraph DynamoDB
Tracking[Sent Digests<br/>Deduplication]
end
subgraph Ghost CMS
Posts[Recent Posts]
Newsletter[Email Newsletter]
Subscribers[All Members]
end
subgraph Monitoring
SNS[SNS Notifications]
CloudWatch[Logs & Metrics]
end
Schedule --> Handler
Handler --> Content
Content --> Posts
Handler --> Tracking
Handler --> Lexical
Lexical --> Admin
Admin --> Newsletter
Newsletter --> Subscribers
Handler --> SNS
Handler --> CloudWatch
The Lambda function performs these steps: check if digest was already sent this week, fetch posts from the last 7 days using Content API, generate Lexical format with bookmark cards, create and publish newsletter via Admin API, record success in DynamoDB, and send SNS notification.
Generating Ghost's Lexical Format
Ghost uses Lexical, a modern editor framework, for its content. Creating newsletters programmatically requires generating valid Lexical JSON. The format is undocumented but can be reverse-engineered by examining Ghost's editor output.
function generateDigestLexical(posts: DigestPost[]): string {
const children: any[] = [];
// Add header with post count and date range
children.push({
children: [
{
detail: 0,
format: 0,
mode: "normal",
style: "",
text: `${posts.length} new ${posts.length === 1 ? 'post' : 'posts'} this week • ${formatWeekRange()}`,
type: "text",
version: 1
}
],
direction: "ltr",
format: "",
indent: 0,
type: "paragraph",
version: 1
});
// Add separator paragraph
children.push({
children: [],
direction: null,
format: "",
indent: 0,
type: "paragraph",
version: 1
});
// Add bookmark card for each post
posts.forEach((post) => {
const postUrl = post.url || `https://${DOMAIN_NAME}/${post.slug}/`;
children.push({
type: "bookmark",
version: 1,
url: postUrl,
metadata: {
icon: "https://static.ghost.org/v5.0.0/images/link-icon.svg",
title: post.title,
description: post.excerpt || "",
author: post.authors?.[0]?.name || DOMAIN_NAME,
publisher: DOMAIN_NAME,
thumbnail: post.feature_image || ""
},
caption: ""
});
// Add paragraph separator between bookmarks
children.push({
children: [],
direction: null,
format: "",
indent: 0,
type: "paragraph",
version: 1
});
});
// Create the Lexical root structure
return JSON.stringify({
root: {
children: children,
direction: null,
format: "",
indent: 0,
type: "root",
version: 1
}
});
}
The Lexical format requires specific version numbers and type fields. Bookmark cards are the key to creating rich previews - they display with title, description, and thumbnail image.
Deduplication with DynamoDB
To prevent sending duplicate digests if the Lambda retries or runs multiple times, the system tracks sent digests in DynamoDB. Each week gets a unique identifier based on the year and week number.
async function wasDigestSentThisWeek(weekId: string): Promise<boolean> {
try {
const command = new GetItemCommand({
TableName: TRACKING_TABLE_NAME,
Key: {
'PK': { S: `DIGEST#${weekId}` },
'SK': { S: 'SENT' }
}
});
const response = await dynamoClient.send(command);
return !!response.Item;
} catch (error) {
console.error('Error checking digest status:', error);
return false;
}
}
async function recordDigestSent(weekId: string, digestId: string): Promise<void> {
const ttl = Math.floor(Date.now() / 1000) + (90 * 24 * 60 * 60); // 90 days
try {
const command = new PutItemCommand({
TableName: TRACKING_TABLE_NAME,
Item: {
'PK': { S: `DIGEST#${weekId}` },
'SK': { S: 'SENT' },
'digestId': { S: digestId },
'sentAt': { S: new Date().toISOString() },
'ttl': { N: ttl.toString() }
}
});
await dynamoClient.send(command);
} catch (error) {
console.error('Error recording digest sent:', error);
throw error;
}
}
The TTL attribute automatically removes old records after 90 days, keeping the table clean without manual maintenance.
Creating and Sending the Newsletter
Ghost requires two separate API interactions: the Content API to read posts and the Admin API to create newsletters. The Admin API needs special handling for email-only posts and newsletter association.
async function createDigestNewsletter(
adminAPI: GhostAdminAPI,
posts: DigestPost[]
): Promise<string> {
const lexicalContent = generateDigestLexical(posts);
const title = `Weekly Digest - ${formatWeekRange()}`;
// Create draft with Lexical content and newsletter association
const draft = await adminAPI.posts.add({
title,
lexical: lexicalContent, // Use Lexical format with bookmark cards
status: 'draft',
email_only: true,
newsletter: NEWSLETTER_SLUG, // Associate with newsletter for tracking
tags: [{name: '#weekly-digest'}] // Internal tag
} as any);
// Publish and send to all members
await adminAPI.posts.edit({
id: draft.id,
status: 'published',
email_only: true,
updated_at: draft.updated_at
} as any, {
email_segment: 'all', // Send to all members (both free and paid)
newsletter: NEWSLETTER_SLUG
} as any);
return draft.id;
}
The email_only flag ensures the post doesn't appear on the site. The newsletter association is critical - without it, Ghost won't send the email even when published.
CDK Infrastructure
The infrastructure uses EventBridge for scheduling, with all components defined in a reusable CDK construct.
export class GhostWeeklyDigest extends Construct {
public readonly function: NodejsFunction;
public readonly table: dynamodb.Table;
public readonly notificationTopic: sns.Topic;
public readonly rule: events.Rule;
constructor(scope: Construct, id: string, props: GhostWeeklyDigestProps) {
super(scope, id);
// DynamoDB table for tracking sent digests
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',
removalPolicy: cdk.RemovalPolicy.DESTROY,
pointInTimeRecovery: true,
});
// Lambda function for digest generation
this.function = new NodejsFunction(this, 'Function', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'handler',
entry: path.join(__dirname, '../../lambda/weekly-digest.ts'),
timeout: cdk.Duration.minutes(5),
memorySize: 512,
environment: {
GHOST_URL: props.ghostUrl,
DOMAIN_NAME: props.domainName,
TRACKING_TABLE_NAME: this.table.tableName,
SNS_TOPIC_ARN: this.notificationTopic.topicArn,
SECRET_ARN: props.ghostApiSecret.secretArn,
NEWSLETTER_SLUG: props.newsletterSlug || 'weekly-digest',
MIN_POSTS: (props.minPosts || 1).toString(),
DIGEST_DAY_RANGE: (props.digestDayRange || 7).toString(),
DRY_RUN: (props.dryRun || false).toString(),
},
});
// EventBridge rule for weekly schedule
this.rule = new events.Rule(this, 'Schedule', {
schedule: props.schedule || events.Schedule.expression('cron(0 9 ? * MON *)'),
description: 'Trigger weekly digest generation',
});
this.rule.addTarget(new targets.LambdaFunction(this.function));
// Grant permissions
this.table.grantReadWriteData(this.function);
this.notificationTopic.grantPublish(this.function);
props.ghostApiSecret.grantRead(this.function);
}
}
The construct encapsulates all resources and permissions, making it easy to deploy in any CDK stack.
Fetching Recent Posts
The Content API provides access to published posts. The function fetches posts from the last week and filters for the configured minimum.
async function getRecentPosts(contentAPI: GhostContentAPI): Promise<DigestPost[]> {
const startDate = new Date();
startDate.setDate(startDate.getDate() - DIGEST_DAY_RANGE);
try {
const posts = await contentAPI.posts.browse({
limit: 'all',
filter: `published_at:>='${startDate.toISOString()}'`,
include: 'tags,authors',
fields: 'id,title,slug,excerpt,feature_image,published_at,reading_time,url',
order: 'published_at DESC'
});
return posts.map(post => ({
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
feature_image: post.feature_image,
published_at: post.published_at,
reading_time: post.reading_time || 1,
primary_tag: post.tags?.[0] || null,
authors: post.authors || [],
url: post.url
}));
} catch (error) {
console.error('Error fetching recent posts:', error);
throw new Error('Failed to fetch recent posts from Ghost');
}
}
The filter uses Ghost's query language to get posts newer than the start date. Including tags and authors provides metadata for the newsletter.
Error Handling and Monitoring
The Lambda includes comprehensive error handling and sends notifications via SNS for both success and failure cases.
export const handler: ScheduledHandler = async (event) => {
const startTime = Date.now();
let result: ProcessingResult;
try {
// Get week identifier (YYYY-WW format)
const weekId = getWeekId();
console.log(`Processing digest for week: ${weekId}`);
// Check if already sent
if (!DRY_RUN && await wasDigestSentThisWeek(weekId)) {
console.log('Digest already sent this week');
result = {
success: false,
postCount: 0,
message: 'Digest already sent this week'
};
return result;
}
// Get Ghost API credentials
const { adminKey, contentKey } = await getGhostCredentials();
// Initialize Ghost APIs
const adminAPI = new GhostAdminAPI({
url: GHOST_URL,
key: adminKey,
version: 'v5.0'
});
const contentAPI = new GhostContentAPI({
url: GHOST_URL,
key: contentKey,
version: 'v5.0'
});
// Get recent posts
const posts = await getRecentPosts(contentAPI);
if (posts.length < MIN_POSTS) {
result = {
success: false,
postCount: posts.length,
message: `Not enough posts (${posts.length} < ${MIN_POSTS})`
};
await sendNotification(result.message, 'Weekly Digest Skipped');
return result;
}
// Create and send digest
const digestId = await createDigestNewsletter(adminAPI, posts);
// Record success
if (!DRY_RUN) {
await recordDigestSent(weekId, digestId);
}
const duration = Date.now() - startTime;
result = {
success: true,
postCount: posts.length,
digestId,
message: `Digest sent with ${posts.length} posts (${duration}ms)`
};
await sendNotification(result.message, 'Weekly Digest Sent Successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Error in digest handler:', error);
result = {
success: false,
postCount: 0,
message: 'Failed to process weekly digest',
error: errorMessage
};
await sendNotification(
`Error: ${errorMessage}\nCheck CloudWatch logs for details`,
'Weekly Digest Failed'
);
}
return result;
};
CloudWatch Logs capture detailed execution information, while SNS notifications provide immediate alerts for success or failure.
Testing and Deployment
The system includes a dry-run mode for testing without sending emails. Set the DRY_RUN environment variable to create draft newsletters without publishing.
# Deploy with dry-run enabled
cdk deploy --context dryRun=true
# Test the Lambda manually
aws lambda invoke \
--function-name ghost-weekly-digest \
--invocation-type RequestResponse \
--payload '{}' \
response.json
# Check the results
cat response.json
Monitor the system through CloudWatch Logs and the SNS email notifications. The DynamoDB table provides an audit trail of all sent digests.
Results
The weekly digest system has been running in production since deployment, automatically sending newsletters every Monday morning. Subscribers receive a cleanly formatted email with bookmark cards for each post, providing an engaging way to catch up on the week's content. The deduplication tracking ensures no duplicate sends, even during Lambda retries or failures.
The Lexical format generation was the most challenging aspect - Ghost's editor format is complex and undocumented. Understanding the bookmark card structure enables rich previews that match Ghost's native editor output. This same approach works for creating any programmatic content in Ghost, from automated reports to content aggregation.