🐛 Fixed retrying failed emails when rescheduling them (#16383)

fixes https://github.com/TryGhost/Team/issues/2560

When an email fails, and you reschedule the post, the error dialog was
shown (from the previous try). The retry button on that page allowed you
to retry sending the email immediately, which could be very confusing.

- The email error dialog is no longer shown for scheduled emails
- The email status is no longer polled for scheduled emails
- Retrying an email is not possible via the API if the post status is
not published or sent
- Added some extra snapshot tests
- When retrying an email, we immediately update the email status to
'pending' to have a better API response (instead of still returning
failed).
- Disabled email sending retrying in development (otherwise very hard to
test failed emails if it takes 10 mins before it gives up automatic
retrying)
This commit is contained in:
Simon Backx 2023-03-09 12:32:22 +01:00 committed by GitHub
parent 0c6a0c64d1
commit 832610fd2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1317 additions and 38 deletions

View File

@ -166,7 +166,7 @@ export default class PublishManagement extends Component {
@task
*publishTask({taskName = 'saveTask'} = {}) {
const willEmail = this.publishOptions.willEmail;
const willEmailImmediately = this.publishOptions.willEmailImmediately;
// clean up blank editor cards
// apply cloned mobiledoc
@ -182,7 +182,7 @@ export default class PublishManagement extends Component {
yield this.args.afterPublish(result);
// if emailed, wait until it has been submitted so we can show a failure message if needed
if (willEmail && this.publishOptions.post.email) {
if (willEmailImmediately && this.publishOptions.post.email) {
yield this.confirmEmailTask.perform();
}
@ -216,6 +216,11 @@ export default class PublishManagement extends Component {
yield post.reload();
if (!post.isSent && !post.isPublished) {
// A post that is not published doesn't try to send or retry an email
break;
}
if (post.email.status === 'submitted') {
break;
}

View File

@ -33,6 +33,10 @@ export default class PublishOptions {
);
}
get willEmailImmediately() {
return this.willEmail && !this.isScheduled;
}
get willPublish() {
return this.publishType !== 'send';
}

View File

@ -24,5 +24,8 @@ module.exports = (model, frame) => {
}
}
// Removed loaded post relation if set
delete jsonModel.post;
return jsonModel;
};

View File

@ -7,7 +7,7 @@ module.exports = function (Bookshelf) {
* Return a relation, and load it if it hasn't been loaded already (or force a refresh with the forceRefresh option).
* refs https://github.com/TryGhost/Team/issues/1626
* @param {string} name Name of the relation to load
* @param {Object} [options] Options to pass to the fetch when not yet loaded (or when force refreshing)
* @param {Object} [options] Options to pass to the fetch when not yet loaded (or when force refreshing)
* @param {boolean} [options.forceRefresh] If true, the relation will be fetched again even if it has already been loaded.
* @returns {Promise<import('bookshelf').Model|import('bookshelf').Collection|null>}
*/
@ -23,8 +23,10 @@ module.exports = function (Bookshelf) {
// Not yet loaded, or force refresh
// Note that we don't use .refresh on the relation on options.forceRefresh
// Because the relation can also be a collection, which doesn't have a refresh method
this.relations[name] = this[name]();
return this.relations[name].fetch(options);
const instance = this[name]();
await instance.fetch(options);
this.relations[name] = instance;
return instance;
}
});
};

View File

@ -753,7 +753,7 @@ Object {
"reply_to": null,
"source": null,
"source_type": "html",
"status": "failed",
"status": "pending",
"subject": "You got mailed! Again!",
"submitted_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"track_clicks": false,
@ -769,7 +769,7 @@ exports[`Emails API Can retry a failed email 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "687",
"content-length": "688",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -61,6 +61,7 @@ describe('Email Preview API', function () {
});
it('can read post email preview with fields', async function () {
const defaultNewsletter = await models.Newsletter.getDefaultNewsletter();
await agent
.get(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
.expectStatus(200)
@ -68,10 +69,19 @@ describe('Email Preview API', function () {
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot(matchEmailPreviewBody);
.matchBodySnapshot(matchEmailPreviewBody)
.expect(({body}) => {
testCleanedSnapshot(body.email_previews[0].html, {
[defaultNewsletter.get('uuid')]: 'requested-newsletter-uuid'
});
testCleanedSnapshot(body.email_previews[0].plaintext, {
[defaultNewsletter.get('uuid')]: 'requested-newsletter-uuid'
});
});
});
it('can read post email preview with email card and replacements', async function () {
const defaultNewsletter = await models.Newsletter.getDefaultNewsletter();
const post = testUtils.DataGenerator.forKnex.createPost({
id: ObjectId().toHexString(),
title: 'Post with email-only card',
@ -93,7 +103,15 @@ describe('Email Preview API', function () {
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot(matchEmailPreviewBody);
.matchBodySnapshot(matchEmailPreviewBody)
.expect(({body}) => {
testCleanedSnapshot(body.email_previews[0].html, {
[defaultNewsletter.get('uuid')]: 'requested-newsletter-uuid'
});
testCleanedSnapshot(body.email_previews[0].plaintext, {
[defaultNewsletter.get('uuid')]: 'requested-newsletter-uuid'
});
});
});
it('has custom content transformations for email compatibility', async function () {
@ -125,6 +143,9 @@ describe('Email Preview API', function () {
assert.doesNotMatch(body.email_previews[0].html, /Testing links in email excerpt and apostrophes &apos;/);
assert.match(body.email_previews[0].html, /Testing links in email excerpt and apostrophes &#39;/);
testCleanedSnapshot(body.email_previews[0].html, {
[defaultNewsletter.get('uuid')]: 'requested-newsletter-uuid'
});
testCleanedSnapshot(body.email_previews[0].plaintext, {
[defaultNewsletter.get('uuid')]: 'requested-newsletter-uuid'
});

View File

@ -76,14 +76,14 @@ class BatchSendingService {
if (BEFORE_RETRY_CONFIG) {
this.#BEFORE_RETRY_CONFIG = BEFORE_RETRY_CONFIG;
} else {
if (process.env.NODE_ENV.startsWith('test')) {
if (process.env.NODE_ENV.startsWith('test') || process.env.NODE_ENV === 'development') {
this.#BEFORE_RETRY_CONFIG = {maxRetries: 0};
}
}
if (AFTER_RETRY_CONFIG) {
this.#AFTER_RETRY_CONFIG = AFTER_RETRY_CONFIG;
} else {
if (process.env.NODE_ENV.startsWith('test')) {
if (process.env.NODE_ENV.startsWith('test') || process.env.NODE_ENV === 'development') {
this.#AFTER_RETRY_CONFIG = {maxRetries: 0};
}
}
@ -91,7 +91,7 @@ class BatchSendingService {
if (MAILGUN_API_RETRY_CONFIG) {
this.#MAILGUN_API_RETRY_CONFIG = MAILGUN_API_RETRY_CONFIG;
} else {
if (process.env.NODE_ENV.startsWith('test')) {
if (process.env.NODE_ENV.startsWith('test') || process.env.NODE_ENV === 'development') {
this.#MAILGUN_API_RETRY_CONFIG = {maxRetries: 0};
}
}

View File

@ -18,7 +18,8 @@ const logging = require('@tryghost/logging');
const messages = {
archivedNewsletterError: 'Cannot send email to archived newsletters',
missingNewsletterError: 'The post does not have a newsletter relation',
emailSendingDisabled: `Email sending is temporarily disabled because your account is currently in review. You should have an email about this from us already, but you can also reach us any time at support@ghost.org`
emailSendingDisabled: `Email sending is temporarily disabled because your account is currently in review. You should have an email about this from us already, but you can also reach us any time at support@ghost.org`,
retryEmailStatusError: 'Can only retry emails for published posts'
};
class EmailService {
@ -156,7 +157,22 @@ class EmailService {
return email;
}
async retryEmail(email) {
// Block accidentaly retrying non-published posts (can happen due to bugs in frontend)
const post = await email.getLazyRelation('post');
if (post.get('status') !== 'published' && post.get('status') !== 'sent') {
throw new errors.IncorrectUsageError({
message: tpl(messages.retryEmailStatusError)
});
}
await this.checkLimits();
// Change email status back to 'pending' before scheduling
// so we have a immediate response when retrying an email (schedule can take a while to kick off sometimes)
if (email.get('status') === 'failed') {
await email.save({status: 'pending'}, {patch: true});
}
this.#batchSendingService.scheduleEmail(email);
return email;
}

View File

@ -17,6 +17,18 @@ describe('Batch Sending Service', function () {
sinon.restore();
});
describe('constructor', function () {
it('works in development mode', async function () {
const env = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
try {
new BatchSendingService({});
} finally {
process.env.NODE_ENV = env;
}
});
});
describe('scheduleEmail', function () {
it('schedules email', async function () {
const jobsService = {

View File

@ -253,13 +253,29 @@ describe('Email Service', function () {
it('Schedules email again', async function () {
const email = createModel({
status: 'failed',
error: 'Test error'
error: 'Test error',
post: createModel({
status: 'published'
})
});
await service.retryEmail(email);
sinon.assert.calledOnce(scheduleEmail);
});
it('Does not schedule email again if draft', async function () {
const email = createModel({
status: 'failed',
error: 'Test error',
post: createModel({
status: 'draft'
})
});
await assert.rejects(service.retryEmail(email));
sinon.assert.notCalled(scheduleEmail);
});
it('Checks limits before scheduling', async function () {
const email = createModel({
status: 'failed',