17ec1e8937
ref GRO-54 fixes GRO-63 fixes GRO-62 fixes GRO-69 When the config `hostSettings:managedEmail:enabled` is enabled, or the new flag (`newEmailAddresses`) is enabled for self-hosters, we'll start to check the from addresses of all outgoing emails more strictly. - Current flow: nothing changes if the managedEmail config is not set or the `newEmailAddresses` feature flag is not set - When managedEmail is enabled: never allow to send an email from any chosen email. We always use `mail.from` for all outgoing emails. Custom addresses should be set as replyTo instead. Changing the newsletter sender_email is not allowed anymore (and ignored if it is set). - When managedEmail is enabled with a custom sending domain: if a from address doesn't match the sending domain, we'll default to mail.from and use the original as a replyTo if appropriate and only when no other replyTo was set. A newsletter sender email addresss can only be set to an email address on this domain. - When `newEmailAddresses` is enabled: self hosters are free to set all email addresses to whatever they want, without verification. In addition to that, we stop making up our own email addresses and send from `mail.from` by default instead of generating a `noreply`+ `@` + `sitedomain.com` address A more in depth example of all cases can be seen in `ghost/core/test/integration/services/email-addresses.test.js` Includes lots of new E2E tests for most new situations. Apart from that, all email snapshots are changed because the from and replyTo addresses are now included in snapshots (so we can see unexpected changes in the future). Dropped test coverage requirement, because tests were failing coverage locally, but not in CI Fixed settings test that set the site title to an array - bug tracked in GRO-68
960 lines
36 KiB
JavaScript
960 lines
36 KiB
JavaScript
// Switch these lines once there are useful utils
|
||
// const testUtils = require('./utils');
|
||
const sinon = require('sinon');
|
||
const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events');
|
||
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
|
||
|
||
// Stuff we are testing
|
||
const DomainEvents = require('@tryghost/domain-events');
|
||
|
||
require('./utils');
|
||
const StaffService = require('../index');
|
||
|
||
function testCommonMailData({mailStub, getEmailAlertUsersStub}) {
|
||
getEmailAlertUsersStub.calledWith(
|
||
sinon.match.string,
|
||
sinon.match({transacting: {}, forUpdate: true})
|
||
).should.be.true();
|
||
|
||
// has right from/to address
|
||
mailStub.calledWith(sinon.match({
|
||
from: 'ghost@ghost.example',
|
||
to: 'owner@ghost.org'
|
||
})).should.be.true();
|
||
|
||
// Email HTML contains important bits
|
||
|
||
// Has accent color
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('#ffffff'))
|
||
).should.be.true();
|
||
|
||
// Has email
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('member@example.com'))
|
||
).should.be.true();
|
||
|
||
// Has member url
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('https://admin.ghost.example/#/members/abc'))
|
||
).should.be.true();
|
||
|
||
// Has site url
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('https://ghost.example'))
|
||
).should.be.true();
|
||
|
||
// Has staff admin url
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('https://admin.ghost.example/#/settings/staff/ghost'))
|
||
).should.be.true();
|
||
}
|
||
|
||
function testCommonPaidSubMailData({member, mailStub, getEmailAlertUsersStub}) {
|
||
testCommonMailData({mailStub, getEmailAlertUsersStub});
|
||
getEmailAlertUsersStub.calledWith('paid-started').should.be.true();
|
||
|
||
if (member?.name) {
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '💸 Paid subscription started: Ghost'})
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('💸 Paid subscription started: Ghost'))
|
||
).should.be.true();
|
||
} else {
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '💸 Paid subscription started: member@example.com'})
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('💸 Paid subscription started: member@example.com'))
|
||
).should.be.true();
|
||
}
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Test Tier'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('$50.00/month'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Subscription started on 1 Aug 2022'))
|
||
).should.be.true();
|
||
}
|
||
|
||
function testCommonPaidSubCancelMailData({mailStub, getEmailAlertUsersStub}) {
|
||
testCommonMailData({mailStub, getEmailAlertUsersStub});
|
||
getEmailAlertUsersStub.calledWith('paid-canceled').should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '⚠️ Cancellation: Ghost'})
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('⚠️ Cancellation: Ghost'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Test Tier'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('$50.00/month'))
|
||
).should.be.true();
|
||
}
|
||
|
||
describe('StaffService', function () {
|
||
describe('Constructor', function () {
|
||
it('doesn\'t throw', function () {
|
||
new StaffService({});
|
||
});
|
||
});
|
||
|
||
describe('email notifications:', function () {
|
||
let mailStub;
|
||
let loggingWarningStub;
|
||
let subscribeStub;
|
||
let getEmailAlertUsersStub;
|
||
let service;
|
||
let options = {
|
||
transacting: {},
|
||
forUpdate: true
|
||
};
|
||
let stubs;
|
||
let labs = {
|
||
isSet: () => {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const settingsCache = {
|
||
get: (setting) => {
|
||
if (setting === 'title') {
|
||
return 'Ghost Site';
|
||
} else if (setting === 'accent_color') {
|
||
return '#ffffff';
|
||
}
|
||
return '';
|
||
}
|
||
};
|
||
|
||
const urlUtils = {
|
||
getSiteUrl: () => {
|
||
return 'https://ghost.example';
|
||
},
|
||
urlJoin: (adminUrl,hash,path) => {
|
||
return `${adminUrl}/${hash}${path}`;
|
||
},
|
||
urlFor: () => {
|
||
return 'https://admin.ghost.example';
|
||
}
|
||
};
|
||
|
||
const settingsHelpers = {
|
||
getDefaultEmailDomain: () => {
|
||
return 'ghost.example';
|
||
},
|
||
useNewEmailAddresses: () => {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
beforeEach(function () {
|
||
loggingWarningStub = sinon.stub().resolves();
|
||
mailStub = sinon.stub().resolves();
|
||
subscribeStub = sinon.stub().resolves();
|
||
getEmailAlertUsersStub = sinon.stub().resolves([{
|
||
email: 'owner@ghost.org',
|
||
slug: 'ghost'
|
||
}]);
|
||
service = new StaffService({
|
||
logging: {
|
||
warn: loggingWarningStub,
|
||
error: () => {}
|
||
},
|
||
models: {
|
||
User: {
|
||
getEmailAlertUsers: getEmailAlertUsersStub
|
||
}
|
||
},
|
||
mailer: {
|
||
send: mailStub
|
||
},
|
||
DomainEvents: {
|
||
subscribe: subscribeStub
|
||
},
|
||
settingsCache,
|
||
urlUtils,
|
||
settingsHelpers,
|
||
labs
|
||
});
|
||
stubs = {mailStub, getEmailAlertUsersStub};
|
||
});
|
||
afterEach(function () {
|
||
sinon.restore();
|
||
});
|
||
|
||
describe('subscribeEvents', function () {
|
||
it('subscribes to events', async function () {
|
||
service.subscribeEvents();
|
||
subscribeStub.callCount.should.eql(4);
|
||
subscribeStub.calledWith(SubscriptionActivatedEvent).should.be.true();
|
||
subscribeStub.calledWith(SubscriptionCancelledEvent).should.be.true();
|
||
subscribeStub.calledWith(MemberCreatedEvent).should.be.true();
|
||
subscribeStub.calledWith(MilestoneCreatedEvent).should.be.true();
|
||
});
|
||
|
||
it('listens to events', async function () {
|
||
service = new StaffService({
|
||
logging: {
|
||
warn: () => {},
|
||
error: () => {}
|
||
},
|
||
models: {
|
||
User: {
|
||
getEmailAlertUsers: getEmailAlertUsersStub
|
||
}
|
||
},
|
||
mailer: {
|
||
send: mailStub
|
||
},
|
||
DomainEvents,
|
||
settingsCache,
|
||
urlUtils,
|
||
settingsHelpers
|
||
});
|
||
service.subscribeEvents();
|
||
sinon.spy(service, 'handleEvent');
|
||
DomainEvents.dispatch(MemberCreatedEvent.create({
|
||
source: 'member',
|
||
memberId: 'member-2'
|
||
}));
|
||
await DomainEvents.allSettled();
|
||
service.handleEvent.calledWith(MemberCreatedEvent).should.be.true();
|
||
|
||
DomainEvents.dispatch(SubscriptionActivatedEvent.create({
|
||
source: 'member',
|
||
memberId: 'member-1',
|
||
subscriptionId: 'sub-1',
|
||
offerId: 'offer-1',
|
||
tierId: 'tier-1'
|
||
}));
|
||
await DomainEvents.allSettled();
|
||
service.handleEvent.calledWith(SubscriptionActivatedEvent).should.be.true();
|
||
|
||
DomainEvents.dispatch(SubscriptionCancelledEvent.create({
|
||
source: 'member',
|
||
memberId: 'member-1',
|
||
subscriptionId: 'sub-1',
|
||
tierId: 'tier-1'
|
||
}));
|
||
await DomainEvents.allSettled();
|
||
service.handleEvent.calledWith(SubscriptionCancelledEvent).should.be.true();
|
||
|
||
DomainEvents.dispatch(MilestoneCreatedEvent.create({
|
||
milestone: {
|
||
type: 'arr',
|
||
value: '100',
|
||
currency: 'usd'
|
||
}
|
||
}));
|
||
await DomainEvents.allSettled();
|
||
service.handleEvent.calledWith(MilestoneCreatedEvent).should.be.true();
|
||
});
|
||
});
|
||
|
||
describe('handleEvent', function () {
|
||
beforeEach(function () {
|
||
const models = {
|
||
User: {
|
||
getEmailAlertUsers: sinon.stub().resolves([{
|
||
email: 'owner@ghost.org',
|
||
slug: 'ghost'
|
||
}]),
|
||
findAll: sinon.stub().resolves([{
|
||
toJSON: sinon.stub().returns({
|
||
email: 'owner@ghost.org',
|
||
slug: 'ghost'
|
||
})
|
||
}])
|
||
},
|
||
Member: {
|
||
findOne: sinon.stub().resolves({
|
||
toJSON: sinon.stub().returns({
|
||
id: '1',
|
||
email: 'jamie@example.com',
|
||
name: 'Jamie',
|
||
status: 'free',
|
||
geolocation: null,
|
||
created_at: '2022-08-01T07:30:39.882Z'
|
||
})
|
||
})
|
||
},
|
||
Product: {
|
||
findOne: sinon.stub().resolves({
|
||
toJSON: sinon.stub().returns({
|
||
id: 'tier-1',
|
||
name: 'Tier 1'
|
||
})
|
||
})
|
||
},
|
||
Offer: {
|
||
findOne: sinon.stub().resolves({
|
||
toJSON: sinon.stub().returns({
|
||
discount_amount: 1000,
|
||
duration: 'forever',
|
||
discount_type: 'fixed',
|
||
name: 'Test offer',
|
||
duration_in_months: null
|
||
})
|
||
})
|
||
},
|
||
StripeCustomerSubscription: {
|
||
findOne: sinon.stub().resolves({
|
||
toJSON: sinon.stub().returns({
|
||
id: 'sub-1',
|
||
plan: {
|
||
amount: 5000,
|
||
currency: 'USD',
|
||
interval: 'month'
|
||
},
|
||
start_date: new Date('2022-08-01T07:30:39.882Z'),
|
||
current_period_end: '2024-08-01T07:30:39.882Z',
|
||
cancellation_reason: 'Changed my mind!'
|
||
})
|
||
})
|
||
}
|
||
};
|
||
|
||
service = new StaffService({
|
||
logging: {
|
||
warn: () => {},
|
||
error: () => {}
|
||
},
|
||
models: models,
|
||
mailer: {
|
||
send: mailStub
|
||
},
|
||
DomainEvents: {
|
||
subscribe: subscribeStub
|
||
},
|
||
settingsCache,
|
||
urlUtils,
|
||
settingsHelpers,
|
||
labs: {
|
||
isSet: () => {
|
||
return false;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
it('handles free member created event', async function () {
|
||
await service.handleEvent(MemberCreatedEvent, {
|
||
data: {
|
||
source: 'member',
|
||
memberId: 'member-1'
|
||
}
|
||
});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '🥳 Free member signup: Jamie'})
|
||
).should.be.true();
|
||
});
|
||
|
||
it('handles paid member created event', async function () {
|
||
await service.handleEvent(SubscriptionActivatedEvent, {
|
||
data: {
|
||
source: 'member',
|
||
memberId: 'member-1',
|
||
subscriptionId: 'sub-1',
|
||
offerId: 'offer-1',
|
||
tierId: 'tier-1'
|
||
}
|
||
});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '💸 Paid subscription started: Jamie'})
|
||
).should.be.true();
|
||
});
|
||
|
||
it('handles paid member cancellation event', async function () {
|
||
await service.handleEvent(SubscriptionCancelledEvent, {
|
||
data: {
|
||
source: 'member',
|
||
memberId: 'member-1',
|
||
subscriptionId: 'sub-1',
|
||
tierId: 'tier-1'
|
||
}
|
||
});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '⚠️ Cancellation: Jamie'})
|
||
).should.be.true();
|
||
});
|
||
|
||
it('handles milestone created event', async function () {
|
||
await service.handleEvent(MilestoneCreatedEvent, {
|
||
data: {
|
||
milestone: {
|
||
type: 'arr',
|
||
value: '1000',
|
||
currency: 'usd',
|
||
emailSentAt: Date.now()
|
||
}
|
||
}
|
||
});
|
||
mailStub.calledWith(
|
||
sinon.match({subject: `Ghost Site hit $1,000 ARR`})
|
||
).should.be.true();
|
||
});
|
||
});
|
||
|
||
describe('notifyFreeMemberSignup', function () {
|
||
it('sends free member signup alert', async function () {
|
||
const member = {
|
||
name: 'Ghost',
|
||
email: 'member@example.com',
|
||
id: 'abc',
|
||
geolocation: '{"country": "France"}',
|
||
created_at: '2022-08-01T07:30:39.882Z'
|
||
};
|
||
|
||
await service.emails.notifyFreeMemberSignup({member}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonMailData(stubs);
|
||
getEmailAlertUsersStub.calledWith('free-signup').should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '🥳 Free member signup: Ghost'})
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('🥳 Free member signup: Ghost'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Created on 1 Aug 2022 • France'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('sends free member signup alert without member name', async function () {
|
||
const member = {
|
||
email: 'member@example.com',
|
||
id: 'abc',
|
||
geolocation: '{"country": "France"}',
|
||
created_at: '2022-08-01T07:30:39.882Z'
|
||
};
|
||
|
||
await service.emails.notifyFreeMemberSignup({member}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonMailData(stubs);
|
||
getEmailAlertUsersStub.calledWith('free-signup').should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '🥳 Free member signup: member@example.com'})
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('🥳 Free member signup: member@example.com'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Created on 1 Aug 2022 • France'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('sends free member signup alert with attribution', async function () {
|
||
const member = {
|
||
name: 'Ghost',
|
||
email: 'member@example.com',
|
||
id: 'abc',
|
||
geolocation: '{"country": "France"}',
|
||
created_at: '2022-08-01T07:30:39.882Z'
|
||
};
|
||
|
||
const attribution = {
|
||
referrerSource: 'Twitter',
|
||
title: 'Welcome Post',
|
||
url: 'https://example.com/welcome'
|
||
};
|
||
|
||
await service.emails.notifyFreeMemberSignup({member, attribution}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonMailData(stubs);
|
||
getEmailAlertUsersStub.calledWith('free-signup').should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match({subject: '🥳 Free member signup: Ghost'})
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('🥳 Free member signup: Ghost'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Created on 1 Aug 2022 • France'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Source'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Twitter'))
|
||
).should.be.true();
|
||
|
||
// check attribution page
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Welcome Post'))
|
||
).should.be.true();
|
||
|
||
// check attribution url
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('https://example.com/welcome'))
|
||
).should.be.true();
|
||
});
|
||
});
|
||
|
||
describe('notifyPaidSubscriptionStart', function () {
|
||
let member;
|
||
let tier;
|
||
let offer;
|
||
let subscription;
|
||
before(function () {
|
||
member = {
|
||
name: 'Ghost',
|
||
email: 'member@example.com',
|
||
id: 'abc',
|
||
geolocation: '{"country": "France"}',
|
||
created_at: '2022-08-01T07:30:39.882Z'
|
||
};
|
||
offer = {
|
||
name: 'Half price',
|
||
duration: 'once',
|
||
type: 'percent',
|
||
amount: 50
|
||
};
|
||
tier = {
|
||
name: 'Test Tier'
|
||
};
|
||
|
||
subscription = {
|
||
amount: 5000,
|
||
currency: 'USD',
|
||
interval: 'month',
|
||
startDate: '2022-08-01T07:30:39.882Z'
|
||
};
|
||
});
|
||
|
||
it('sends paid subscription start alert with attribution', async function () {
|
||
const attribution = {
|
||
referrerSource: 'Twitter',
|
||
title: 'Welcome Post',
|
||
url: 'https://example.com/welcome'
|
||
};
|
||
await service.emails.notifyPaidSubscriptionStarted({member, offer: null, tier, subscription, attribution}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubMailData({...stubs, member});
|
||
|
||
// check attribution text
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Twitter'))
|
||
).should.be.true();
|
||
|
||
// check attribution text
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Source'))
|
||
).should.be.true();
|
||
|
||
// check attribution page
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Welcome Post'))
|
||
).should.be.true();
|
||
|
||
// check attribution url
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('https://example.com/welcome'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('sends paid subscription start alert without offer', async function () {
|
||
await service.emails.notifyPaidSubscriptionStarted({member, offer: null, tier, subscription}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubMailData({...stubs, member});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', 'Offer')
|
||
).should.be.false();
|
||
});
|
||
|
||
it('sends paid subscription start alert without member name', async function () {
|
||
let memberData = {
|
||
email: 'member@example.com',
|
||
id: 'abc',
|
||
geolocation: '{"country": "France"}',
|
||
created_at: '2022-08-01T07:30:39.882Z'
|
||
};
|
||
await service.emails.notifyPaidSubscriptionStarted({member: memberData, offer: null, tier, subscription}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubMailData({...stubs, member: memberData});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', 'Offer')
|
||
).should.be.false();
|
||
|
||
// check preview text
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Test Tier: $50.00/month'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('sends paid subscription start alert with percent offer - first payment', async function () {
|
||
await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubMailData({...stubs, member});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Half price'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('50% off'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('first payment'))
|
||
).should.be.true();
|
||
|
||
// check preview text
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Test Tier: $50.00/month - Offer: Half price - 50% off, first payment'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('sends paid subscription start alert with fixed type offer - repeating duration', async function () {
|
||
offer = {
|
||
name: 'Save ten',
|
||
duration: 'repeating',
|
||
durationInMonths: 3,
|
||
type: 'fixed',
|
||
currency: 'USD',
|
||
amount: 1000
|
||
};
|
||
|
||
await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubMailData({...stubs, member});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Save ten'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('$10.00 off'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('first 3 months'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('sends paid subscription start alert with fixed type offer - forever duration', async function () {
|
||
offer = {
|
||
name: 'Save twenty',
|
||
duration: 'forever',
|
||
type: 'fixed',
|
||
currency: 'USD',
|
||
amount: 2000
|
||
};
|
||
|
||
await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubMailData({...stubs, member});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Save twenty'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('$20.00 off'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('forever'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('sends paid subscription start alert with free trial offer', async function () {
|
||
offer = {
|
||
name: 'Free week',
|
||
duration: 'trial',
|
||
type: 'trial',
|
||
amount: 7
|
||
};
|
||
|
||
await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubMailData({...stubs, member});
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Free week'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('7 days free'))
|
||
).should.be.true();
|
||
});
|
||
});
|
||
|
||
describe('notifyPaidSubscriptionCancel', function () {
|
||
let member;
|
||
let tier;
|
||
let subscription;
|
||
before(function () {
|
||
member = {
|
||
name: 'Ghost',
|
||
email: 'member@example.com',
|
||
id: 'abc',
|
||
geolocation: '{"country": "France"}',
|
||
created_at: '2022-08-01T07:30:39.882Z'
|
||
};
|
||
|
||
tier = {
|
||
name: 'Test Tier'
|
||
};
|
||
|
||
subscription = {
|
||
amount: 5000,
|
||
currency: 'USD',
|
||
interval: 'month',
|
||
cancelAt: '2024-08-01T07:30:39.882Z',
|
||
canceledAt: '2022-08-05T07:30:39.882Z'
|
||
};
|
||
});
|
||
|
||
it('sends paid subscription cancel alert', async function () {
|
||
await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription: {
|
||
...subscription,
|
||
cancellationReason: 'Changed my mind!'
|
||
}}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubCancelMailData(stubs);
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Subscription will expire on'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Canceled on 5 Aug 2022'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('1 Aug 2024'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', 'Offer')
|
||
).should.be.false();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Reason: Changed my mind!'))
|
||
).should.be.true();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Cancellation reason'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('sends paid subscription cancel alert without reason', async function () {
|
||
await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription}, options);
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
testCommonPaidSubCancelMailData(stubs);
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Subscription will expire on'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Canceled on 5 Aug 2022'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('1 Aug 2024'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Reason: '))
|
||
).should.be.false();
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Cancellation reason'))
|
||
).should.be.false();
|
||
|
||
// check preview text
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('A paid member has just canceled their subscription.'))
|
||
).should.be.true();
|
||
});
|
||
});
|
||
|
||
describe('notifyMilestoneReceived', function () {
|
||
it('send Members milestone email', async function () {
|
||
const milestone = {
|
||
type: 'members',
|
||
value: 25000,
|
||
emailSentAt: Date.now()
|
||
};
|
||
|
||
await service.emails.notifyMilestoneReceived({milestone});
|
||
|
||
getEmailAlertUsersStub.calledWith('milestone-received').should.be.true();
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Ghost Site now has 25k members'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Celebrating 25,000 signups'))
|
||
).should.be.true();
|
||
|
||
// Correct image and NO height for Members milestone
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('src="https://static.ghost.org/v5.0.0/images/milestone-email-members-25k.png" width="580" align="center"'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Congrats, <strong>25k people</strong> have chosen to support and follow your work. That’s an audience big enough to sell out Madison Square Garden. What an incredible milestone!'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('View your dashboard'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('send ARR milestone email', async function () {
|
||
const milestone = {
|
||
type: 'arr',
|
||
value: 500000,
|
||
currency: 'usd',
|
||
emailSentAt: Date.now()
|
||
};
|
||
|
||
await service.emails.notifyMilestoneReceived({milestone});
|
||
|
||
getEmailAlertUsersStub.calledWith('milestone-received').should.be.true();
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Ghost Site hit $500,000 ARR'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Congrats! You reached $500k ARR'))
|
||
).should.be.true();
|
||
|
||
// Correct image and height for ARR milestone
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('src="https://static.ghost.org/v5.0.0/images/milestone-email-usd-500k.png" width="580" height="348" align="center"'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('<strong>Ghost Site</strong> is now generating <strong>$500,000</strong> in annual recurring revenue. Congratulations — this is a significant milestone.'))
|
||
).should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('Login to your dashboard'))
|
||
).should.be.true();
|
||
});
|
||
|
||
it('does not send email when no date provided', async function () {
|
||
const milestone = {
|
||
type: 'members',
|
||
value: 25000
|
||
};
|
||
|
||
await service.emails.notifyMilestoneReceived({milestone});
|
||
|
||
getEmailAlertUsersStub.calledWith('milestone-received').should.be.false();
|
||
|
||
mailStub.called.should.be.false();
|
||
});
|
||
|
||
it('does not send email when a reason not to send email was provided', async function () {
|
||
const milestone = {
|
||
type: 'members',
|
||
value: 25000,
|
||
emailSentAt: Date.now(),
|
||
meta: {
|
||
reason: 'no-email'
|
||
}
|
||
};
|
||
|
||
await service.emails.notifyMilestoneReceived({milestone});
|
||
|
||
getEmailAlertUsersStub.calledWith('milestone-received').should.be.false();
|
||
|
||
mailStub.called.should.be.false();
|
||
});
|
||
|
||
it('does not send email for a milestone without correct content', async function () {
|
||
const milestone = {
|
||
type: 'members',
|
||
value: 5000, // milestone not configured
|
||
emailSentAt: Date.now()
|
||
};
|
||
|
||
await service.emails.notifyMilestoneReceived({milestone});
|
||
|
||
getEmailAlertUsersStub.calledWith('milestone-received').should.be.false();
|
||
|
||
loggingWarningStub.calledOnce.should.be.true();
|
||
|
||
mailStub.called.should.be.false();
|
||
});
|
||
});
|
||
|
||
describe('notifyDonationReceived', function () {
|
||
it('send donation email', async function () {
|
||
const donationPaymentEvent = {
|
||
amount: 1500,
|
||
currency: 'eur',
|
||
name: 'Simon',
|
||
email: 'simon@example.com'
|
||
};
|
||
|
||
await service.emails.notifyDonationReceived({donationPaymentEvent});
|
||
|
||
getEmailAlertUsersStub.calledWith('donation').should.be.true();
|
||
|
||
mailStub.calledOnce.should.be.true();
|
||
|
||
mailStub.calledWith(
|
||
sinon.match.has('html', sinon.match('One-time payment received: €15.00 from Simon'))
|
||
).should.be.true();
|
||
});
|
||
});
|
||
|
||
describe('renderText for webmentions', function () {
|
||
it('renders plaintext report for mentions', async function () {
|
||
const textTemplate = await service.emails.renderText('mention-report', {
|
||
toEmail: 'jamie@example.com',
|
||
siteDomain: 'ghost.org',
|
||
staffUrl: 'https://admin.example.com/blog/ghost/#/settings/staff/jane.',
|
||
mentions: [
|
||
{
|
||
sourceSiteTitle: 'Webmentions',
|
||
sourceUrl: 'https://webmention.io/'
|
||
},
|
||
{
|
||
sourceSiteTitle: 'Ghost Demo',
|
||
sourceUrl: 'https://demo.ghost.io/'
|
||
}
|
||
]
|
||
});
|
||
textTemplate.should.match(/- Webmentions \(https:\/\/webmention.io\/\)/);
|
||
textTemplate.should.match(/Ghost Demo \(https:\/\/demo.ghost.io\/\)/);
|
||
textTemplate.should.match(/Sent to jamie@example.com from ghost.org/);
|
||
});
|
||
});
|
||
});
|
||
});
|