Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
const path = require('path');
const jobsService = require('../../jobs');
const labs = require('../../../../shared/labs');
const config = require('../../../../shared/config');

let hasScheduled = {
processOutbox: false
};

module.exports = {
async scheduleMemberWelcomeEmailJob() {
if (!labs.isSet('welcomeEmails')) {
if (!config.get('memberWelcomeEmailTestInbox')) {
return false;
}

if (!hasScheduled.processOutbox && !process.env.NODE_ENV.startsWith('test')) {
if (hasScheduled.processOutbox && !process.env.NODE_ENV.startsWith('test')) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Inverted condition prevents welcome email job from ever being scheduled

The condition for scheduling the member welcome email job was changed from !hasScheduled.processOutbox to hasScheduled.processOutbox, inverting the logic.

Click to expand

How the bug is triggered

The hasScheduled.processOutbox flag is initialized to false at module load time (ghost/core/core/server/services/member-welcome-emails/jobs/index.js:5-7):

let hasScheduled = {
    processOutbox: false
};

With the new condition if (hasScheduled.processOutbox && ...), the job will only be scheduled when hasScheduled.processOutbox is true. However, since it starts as false and is only set to true INSIDE the if-block (line 22), the condition can never be satisfied.

Actual vs Expected behavior

Expected: The job should be scheduled on the first call when hasScheduled.processOutbox is false (using !hasScheduled.processOutbox).

Actual: The job is never scheduled because the condition requires hasScheduled.processOutbox to be true, but it's always false until the job is scheduled.

Impact

The member welcome email processing job will never run, meaning welcome emails will never be sent to new members even when the feature is enabled via config.

Recommendation: Change the condition back to if (!hasScheduled.processOutbox && !process.env.NODE_ENV.startsWith('test')) to ensure the job is scheduled on the first call.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

jobsService.addJob({
at: '0 */5 * * * *',
job: path.resolve(__dirname, 'process-outbox.js'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const ObjectId = require('bson-objectid').default;
const {NotFoundError} = require('@tryghost/errors');
const validator = require('@tryghost/validator');
const crypto = require('crypto');
const config = require('../../../../../shared/config');

const messages = {
noStripeConnection: 'Cannot {action} without a Stripe Connection',
Expand Down Expand Up @@ -336,8 +337,9 @@ module.exports = class MemberRepository {
const eventData = _.pick(data, ['created_at']);

const memberAddOptions = {...(options || {}), withRelated};
let member;
if (this._labsService.isSet('welcomeEmails') && WELCOME_EMAIL_SOURCES.includes(source)) {
var member;
const welcomeEmailConfig = config.get('memberWelcomeEmailTestInbox');
if (welcomeEmailConfig || WELCOME_EMAIL_SOURCES.includes(source)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 OR condition causes welcome emails to be sent for all sources when config is set

The condition for creating outbox entries was changed from AND (&&) to OR (||), causing welcome emails to be sent for all member sources (import, admin, api) when the config is set.

Click to expand

How the bug is triggered

The original logic was:

if (this._labsService.isSet('welcomeEmails') && WELCOME_EMAIL_SOURCES.includes(source))

This required BOTH the feature flag to be enabled AND the source to be in the allowed list (['member']).

The new logic is:

if (welcomeEmailConfig || WELCOME_EMAIL_SOURCES.includes(source))

This creates an outbox entry if EITHER the config is set OR the source is 'member'.

Actual vs Expected behavior

Expected: When memberWelcomeEmailTestInbox config is set, welcome emails should only be sent for members created with source 'member' (self-signup). Members created via import, admin, or API should NOT receive welcome emails.

Actual: When memberWelcomeEmailTestInbox config is set, welcome emails are sent for ALL member sources including import, admin, and API.

Evidence from tests

The tests at ghost/core/test/integration/services/member-welcome-emails.test.js:67-94 explicitly expect that imported members and admin-created members should NOT create outbox entries even when the config is set:

it('does NOT create outbox entry when member is imported', async function () {
    configUtils.set('memberWelcomeEmailTestInbox', 'test-inbox@example.com');
    // ...
    assert.equal(outboxEntries.length, 0);
});

Impact

Imported members, admin-created members, and API-created members will incorrectly receive welcome emails when the config is set, which could result in:

  • Spam complaints from bulk-imported members
  • Confusion for members added by admins
  • Unexpected email volume

Recommendation: Change the condition to if (welcomeEmailConfig && WELCOME_EMAIL_SOURCES.includes(source)) to require both the config to be set AND the source to be in the allowed list.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const runMemberCreation = async (transacting) => {
const newMember = await this._Member.add({
...memberData,
Expand Down
19 changes: 9 additions & 10 deletions ghost/core/test/integration/services/member-welcome-emails.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ const testUtils = require('../../utils');
const models = require('../../../core/server/models');
const {OUTBOX_STATUSES} = require('../../../core/server/models/outbox');
const db = require('../../../core/server/data/db');
const labs = require('../../../core/shared/labs');
const sinon = require('sinon');
const configUtils = require('../../utils/configUtils');

describe('Member Welcome Emails Integration', function () {
let membersService;
Expand All @@ -22,12 +21,12 @@ describe('Member Welcome Emails Integration', function () {
afterEach(async function () {
await db.knex('outbox').del();
await db.knex('members').del();
sinon.restore();
await configUtils.restore();
});

describe('Member creation with welcome emails enabled', function () {
it('creates outbox entry when member source is "member"', async function () {
sinon.stub(labs, 'isSet').withArgs('welcomeEmails').returns(true);
configUtils.set('memberWelcomeEmailTestInbox', 'test-inbox@example.com');

const member = await membersService.api.members.create({
email: 'welcome-test@example.com',
Expand All @@ -50,9 +49,9 @@ describe('Member Welcome Emails Integration', function () {
assert.equal(payload.source, 'member');
});

it('does NOT create outbox entry when welcome emails feature is disabled', async function () {
sinon.stub(labs, 'isSet').withArgs('welcomeEmails').returns(false);

it('does NOT create outbox entry when config is not set', async function () {
configUtils.set('memberWelcomeEmailTestInbox', '');
await membersService.api.members.create({
email: 'no-welcome@example.com',
name: 'No Welcome Member'
Expand All @@ -66,7 +65,7 @@ describe('Member Welcome Emails Integration', function () {
});

it('does NOT create outbox entry when member is imported', async function () {
sinon.stub(labs, 'isSet').withArgs('welcomeEmails').returns(true);
configUtils.set('memberWelcomeEmailTestInbox', 'test-inbox@example.com');

await membersService.api.members.create({
email: 'imported@example.com',
Expand All @@ -81,7 +80,7 @@ describe('Member Welcome Emails Integration', function () {
});

it('does NOT create outbox entry when member is created by admin', async function () {
sinon.stub(labs, 'isSet').withArgs('welcomeEmails').returns(true);
configUtils.set('memberWelcomeEmailTestInbox', 'test-inbox@example.com');

await membersService.api.members.create({
email: 'admin-created@example.com',
Expand All @@ -96,7 +95,7 @@ describe('Member Welcome Emails Integration', function () {
});

it('creates outbox entry with correct timestamp', async function () {
sinon.stub(labs, 'isSet').withArgs('welcomeEmails').returns(true);
configUtils.set('memberWelcomeEmailTestInbox', 'test-inbox@example.com');

const beforeCreation = new Date(Date.now() - 1000);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const sinon = require('sinon');
const DomainEvents = require('@tryghost/domain-events');
const MemberRepository = require('../../../../../../../core/server/services/members/members-api/repositories/MemberRepository');
const {SubscriptionCreatedEvent, OfferRedemptionEvent} = require('../../../../../../../core/shared/events');
const config = require('../../../../../../../core/shared/config');

const mockOfferRedemption = {
add: sinon.stub(),
Expand Down Expand Up @@ -466,7 +467,6 @@ describe('MemberRepository', function () {
let Outbox;
let MemberStatusEvent;
let MemberSubscribeEvent;
let labsService;
let newslettersService;
const oldNodeEnv = process.env.NODE_ENV;

Expand Down Expand Up @@ -527,16 +527,13 @@ describe('MemberRepository', function () {
});

it('creates outbox entry for allowed source', async function () {
labsService = {
isSet: sinon.stub().withArgs('welcomeEmails').returns(true)
};
sinon.stub(config, 'get').withArgs('memberWelcomeEmailTestInbox').returns('test-inbox@example.com');

const repo = new MemberRepository({
Member,
Outbox,
MemberStatusEvent,
MemberSubscribeEventModel: MemberSubscribeEvent,
labsService,
newslettersService,
OfferRedemption: mockOfferRedemption
});
Expand All @@ -554,17 +551,14 @@ describe('MemberRepository', function () {
assert.equal(payload.source, 'member');
});

it('does NOT create outbox entry when welcomeEmails lab flag is disabled', async function () {
labsService = {
isSet: sinon.stub().withArgs('welcomeEmails').returns(false)
};
it('does NOT create outbox entry when config is not set', async function () {
sinon.stub(config, 'get').withArgs('memberWelcomeEmailTestInbox').returns(undefined);

const repo = new MemberRepository({
Member,
Outbox,
MemberStatusEvent,
MemberSubscribeEventModel: MemberSubscribeEvent,
labsService,
newslettersService,
OfferRedemption: mockOfferRedemption
});
Expand All @@ -575,16 +569,13 @@ describe('MemberRepository', function () {
});

it('does not create outbox entry for disallowed sources', async function () {
labsService = {
isSet: sinon.stub().withArgs('welcomeEmails').returns(true)
};
sinon.stub(config, 'get').withArgs('memberWelcomeEmailTestInbox').returns('test-inbox@example.com');

const repo = new MemberRepository({
Member,
Outbox,
MemberStatusEvent,
MemberSubscribeEventModel: MemberSubscribeEvent,
labsService,
newslettersService,
OfferRedemption: mockOfferRedemption
});
Expand All @@ -603,16 +594,13 @@ describe('MemberRepository', function () {
});

it('includes timestamp in outbox payload', async function () {
labsService = {
isSet: sinon.stub().withArgs('welcomeEmails').returns(true)
};
sinon.stub(config, 'get').withArgs('memberWelcomeEmailTestInbox').returns('test-inbox@example.com');

const repo = new MemberRepository({
Member,
Outbox,
MemberStatusEvent,
MemberSubscribeEventModel: MemberSubscribeEvent,
labsService,
newslettersService,
OfferRedemption: mockOfferRedemption
});
Expand All @@ -625,16 +613,13 @@ describe('MemberRepository', function () {
});

it('passes transaction to outbox entry creation', async function () {
labsService = {
isSet: sinon.stub().withArgs('welcomeEmails').returns(true)
};
sinon.stub(config, 'get').withArgs('memberWelcomeEmailTestInbox').returns('test-inbox@example.com');

const repo = new MemberRepository({
Member,
Outbox,
MemberStatusEvent,
MemberSubscribeEventModel: MemberSubscribeEvent,
labsService,
newslettersService,
OfferRedemption: mockOfferRedemption
});
Expand Down