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:

  1. Create a Mailgun account and add your domain
  2. Configure DNS records (SPF, DKIM, MX)
  3. Verify domain ownership
  4. 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:

  1. Click "Add newsletter"
  2. Name: "Weekly Digest"
  3. Description: "A weekly roundup of our latest posts"
  4. Sender email: noreply@mg.yourdomain.com
  5. 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.