Silent failure when sending emails for posts created by API
Issue Summary
I created some scheduled posts using the API. Now after one them attempted to send, there’s silent failure with no specific error message in the UI or in the logs.
To start with, here’s what a normal failure-to-send result looks like in the admin UI:
That’s not what happened here. Instead, if you roll over the post in the post list, it says “Sent to members”. But that’s not the normal UX for success either. The normal UX for Success is the message “Sent to 62 members”.
Opening up the post, it reports the same message with the number of users missing:
It shows up in the “Recent Posts” area, but there is no “Sent” or Open Rate" UI even though it was an email-only post:
And this curious: If using the Post filters for “Drafts”, “Scheduled” or “Published”, it doesn’t show up in any category.
I also cannot find anything specific error in the logs around the time email should be sent. Later there’s this generic error message which is not helpful:
[2025-07-19 07:07:06] ERROR Internal Server Error
[2025-07-19 07:07:06] ERROR [EmailAnalytics] Error while fetching
[2025-07-19 07:07:06] ERROR Internal Server Error
[2025-07-19 07:07:06] INFO [EmailAnalytics] No new events found
[2025-07-19 07:07:06] ERROR Error while fetching email analytics Internal Server Error
[2025-07-19 07:07:06] ERROR Internal Server Error
I suspect the issue here is that Ghost expects every email to have email analytics available, but because of the zombie state of this post, it does not have any and this case was not accounted for, resulting in Internal Server Error.
I have about a dozen more scheduled posts created via API the same way, so I’m concerned that without understanding the root cause of this better that none of my posts scheduled for the rest of the year will work.
It seems to me that in one or places there was a data validation failure and when the bad data was attempted to be used, the post got put into an invalid state.
Because there are more scheduled posts that may have this problem, it’s possible that I could provide some kind of database-level export of the tables/rows involved if someone wants to take a look.
I checked on the Mailgun time around the time of the scheduled post and see nothing there.
When I tried duplicating the post and sending today, it worked. That confirms that the flow between Ghost and Mailgun is working fine, and if there was a bad data issue, the duplicate post feature somehow resolved it.
I tried upgrading to 5.130.1, the latest release, and the weird Admin area UX remains: Still showing as “sent” without a particular number of users, but not showing as “Published”. It’s a Heisenpost!
Steps to Reproduce
Assuming I'm right about what the cause, the steps to reproduce are:
- Ask for what you need from my database in terms of posts scheduled like this but yet sent.
- Receive data you need.
- Reproduce on a second instance.
Ghost Version
5.127.2
Node.js Version
20
How did you install Ghost?
container on Fedora Linux 41.
Database type
MySQL 8
Browser & OS version
No response
Relevant log / error output
Code of Conduct
- [x] I agree to be friendly and polite to people in this repository
@markstos can you share exactly how you created and published the post via the API? Ghost's Admin interface works by using the API so it sounds like something specific with the data you're passing as there's no difference between using the UI and using the API if the data is the same
Turns out I saved the script. Here it is.
const GhostAdminAPI = require('@tryghost/admin-api');
const fs = require('fs');
// Initialize the API client
const api = new GhostAdminAPI({
url: 'https://pea-pod.org',
key: process.env.GHOST_API_KEY,
version: 'v5.0'
});
// Parse CSV data
function parseCSV() {
const csvContent = fs.readFileSync('2025-second-half.csv', 'utf8');
const lines = csvContent.trim().split('\n');
const headers = lines[0].split(',');
const events = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',');
const event = {};
headers.forEach((header, index) => {
event[header] = values[index];
});
events.push(event);
}
return events;
}
// Convert date string to proper ISO format for scheduling
function formatDate(dateStr, time = '16:00:00.000Z') {
const [month, day, year] = dateStr.split('/');
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time}`;
}
// Get month name from date
function getMonthName(dateStr) {
const [month] = dateStr.split('/');
const months = ['', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
return months[parseInt(month)];
}
async function createNewsletter(event, templatePost) {
const monthName = getMonthName(event.date);
const year = event.date.split('/')[2];
// Parse template and replace placeholders
let templateLexical = templatePost.lexical;
// Replace {{date}} with formatted date
const eventDate = new Date(formatDate(event.date, '00:00:00.000Z'));
const formattedDate = `${monthName} ${eventDate.getDate()}, ${year}`;
templateLexical = templateLexical.replace(/\{\{date\}\}/g, formattedDate);
// Replace {{video}} with proper embed
const embedCard = {
"type": "embed",
"version": 1,
"url": event.video,
"html": null,
"metadata": {
"url": event.video,
"type": "video",
"title": "Plant-based recipe video",
"description": "",
"author": null,
"publisher": null,
"thumbnail": null,
"icon": null
}
};
let lexicalObj = JSON.parse(templateLexical);
function replaceVideoPlaceholder(obj) {
if (Array.isArray(obj)) {
return obj.map(replaceVideoPlaceholder);
} else if (obj && typeof obj === 'object') {
const newObj = {};
for (const [key, value] of Object.entries(obj)) {
if (key === 'text' && value === '{{video}}') {
return embedCard;
} else {
newObj[key] = replaceVideoPlaceholder(value);
}
}
return newObj;
}
return obj;
}
lexicalObj = replaceVideoPlaceholder(lexicalObj);
templateLexical = JSON.stringify(lexicalObj);
// Create newsletter post
const newsletterPost = {
title: `${monthName} ${year}, PEA Pod Potluck`,
lexical: templateLexical,
feature_image: `https://pea-pod.org/content/images/2025/06/${event.photo}`,
status: 'scheduled',
published_at: formatDate(event['two-weeks-before']),
email_segment: 'all',
visibility: 'public',
featured: false
};
console.log(`Creating ${monthName} newsletter...`);
const createdNewsletter = await api.posts.add(newsletterPost);
// Create reminder post
const reminderPost = {
title: `REMINDER: ${monthName} ${year}, PEA Pod Potluck`,
lexical: templateLexical,
feature_image: `https://pea-pod.org/content/images/2025/06/${event.photo}`,
status: 'scheduled',
published_at: formatDate(event['two-days-before']),
email_segment: 'all',
visibility: 'public',
email_only: true,
featured: false
};
console.log(`Creating ${monthName} reminder...`);
const createdReminder = await api.posts.add(reminderPost);
return {
newsletter: createdNewsletter,
reminder: createdReminder,
month: monthName,
eventDate: formattedDate
};
}
async function main() {
try {
// Get the template post
console.log('Fetching template post...');
const templatePost = await api.posts.read(
{ id: '64cfe32331c74a31fdcdf757' },
{ formats: ['lexical', 'html'] }
);
// Parse CSV data (skip July since it's already done)
const events = parseCSV().slice(1); // Skip July (first row)
console.log(`Creating newsletters and reminders for ${events.length} months...\n`);
const results = [];
for (const event of events) {
try {
const result = await createNewsletter(event, templatePost);
results.push(result);
console.log(`✅ ${result.month} complete:`);
console.log(` Newsletter ID: ${result.newsletter.id}`);
console.log(` Reminder ID: ${result.reminder.id}`);
console.log(` Event Date: ${result.eventDate}`);
console.log(` Newsletter scheduled: ${result.newsletter.published_at}`);
console.log(` Reminder scheduled: ${result.reminder.published_at}\n`);
} catch (error) {
console.error(`❌ Error creating ${getMonthName(event.date)} posts:`, error.message);
}
}
console.log('\n🎉 All newsletters and reminders created successfully!');
console.log('\nSummary:');
results.forEach(result => {
console.log(`${result.month}: Newsletter (${result.newsletter.id}) + Reminder (${result.reminder.id})`);
});
} catch (error) {
console.error('Error:', error.message);
if (error.response) {
console.error('Response data:', error.response.data);
}
}
}
main();
The CSV has a structure like this:
date,two-weeks-before,two-days-before,photo,video
12/21/2025,12/7/2025,12/19/2025,2025-12-newsletter-photo.jpg,https://www.youtube.com/watch?v=5NMdpAVGzp0
I think the issue is likely that your API request to create the post isn't set up to email it as well. To email posts via the API you need to include the newsletter slug and optional segment (default is 'all') in query params in an update request, e.g. PUT /posts/:post_id/?newsletter=xyz&email_segment=all. The query params are what dictates a save&send from a normal save, otherwise it becomes very easy to accidentally send a post via normal update/save operations. The post also needs to exist before the send request can be made.
What's happened with your posts is that they've been created but not had a newsletter association created by a subsequent send update, then when it's come time for the scheduled publish there's nothing to send and nothing to publish to the site as it's email-only so the post has ended up in an odd state that our UI isn't set up to handle.
Docs for sending via API are here https://ghost.org/docs/admin-api/#sending-a-post-via-email
And this curious: If using the Post filters for “Drafts”, “Scheduled” or “Published”, it doesn’t show up in any category.
This is expected, at least from the technical level, because those filters look for specific status values of 'draft/scheduled/published' but email-only posts have a status of 'sent'. We don't currently have a filter for finding sent email-only posts.
If there's a bug here, it's that the API should error when setting the scheduled status on an email-only post with no newsletter reference set.
Our bot has automatically marked this issue as stale because there has not been any activity here in some time.
The issue will be closed soon if there are no further updates, however we ask that you do not post comments to keep the issue open if you are not actively working on a PR.
We keep the issue list minimal so we can keep focus on the most pressing issues. Closed issues can always be reopened if a new contributor is found. Thank you for understanding 🙂
This is a real issue with an open PR that would actually resolve it. The pull request has been waiting five months for a review.