mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-08 12:09:43 +03:00
Added configurable target delivery window for batch sending (#20719)
ref https://linear.app/tryghost/issue/ONC-217/implement-the-deliverytime-option-in-mailgun-api-calls Ghost experiences its highest peak load immediately after sending out a newsletter, as it recieves an influx of traffic from users clicking on the links in the email, a burst of email analytics events to process from mailgun, and an increase in organic traffic to the site's frontend as well as the admin analytics pages. The `BatchSendingService` currently sends all the batches to Mailgun as quickly as possible, which may contribute to higher peak loads. This commit adds a `deliverytime` parameter to our API calls to Mailgun, which allows us to specify a time in the future when we want the email to be delivered. This will allow us to moderate the rate at which emails are delivered, and in turn that should moderate the peak traffic volume that Ghost receives in the first 2-3 minutes after sending an email. The `deliverytime` is calculated based on a configurable parameter: `bulkEmail.targetDeliveryWindow`, which specifies the maximum allowable time (in milliseconds) after the email is first sent for Ghost to instruct Mailgun to deliver the emails. Ghost will attempt to space out all the batches as evenly as possible throughout the specified window. For example, if the targetDeliveryWindow is set to `300000` (5 minutes) and there are 100 batches, Ghost will set the `deliveryTime` for each batch ~3 seconds apart.
This commit is contained in:
parent
55e6166618
commit
ee514a397c
@ -552,6 +552,63 @@ describe('Batch sending tests', function () {
|
||||
await configUtils.restore();
|
||||
});
|
||||
|
||||
describe('Target Delivery Window', function () {
|
||||
it('can send an email with a target delivery window set', async function () {
|
||||
const t0 = new Date();
|
||||
const targetDeliveryWindow = 240000; // 4 minutes
|
||||
configUtils.set('bulkEmail:batchSize', 1);
|
||||
configUtils.set('bulkEmail:targetDeliveryWindow', targetDeliveryWindow);
|
||||
const {emailModel} = await sendEmail(agent);
|
||||
|
||||
assert.equal(emailModel.get('source_type'), 'lexical');
|
||||
assert(emailModel.get('subject'));
|
||||
assert(emailModel.get('from'));
|
||||
assert.equal(emailModel.get('email_count'), 4);
|
||||
|
||||
// Did we create batches?
|
||||
const batches = await models.EmailBatch.findAll({filter: `email_id:'${emailModel.id}'`});
|
||||
assert.equal(batches.models.length, 4);
|
||||
|
||||
// Check all batches are in send state
|
||||
for (const batch of batches.models) {
|
||||
assert.equal(batch.get('provider_id'), 'stubbed-email-id');
|
||||
assert.equal(batch.get('status'), 'submitted');
|
||||
assert.equal(batch.get('member_segment'), null);
|
||||
|
||||
assert.equal(batch.get('error_status_code'), null);
|
||||
assert.equal(batch.get('error_message'), null);
|
||||
assert.equal(batch.get('error_data'), null);
|
||||
}
|
||||
|
||||
// Did we create recipients?
|
||||
const emailRecipients = await models.EmailRecipient.findAll({filter: `email_id:'${emailModel.id}'`});
|
||||
assert.equal(emailRecipients.models.length, 4);
|
||||
|
||||
for (const recipient of emailRecipients.models) {
|
||||
const batchId = recipient.get('batch_id');
|
||||
assert.ok(batches.models.find(b => b.id === batchId));
|
||||
}
|
||||
|
||||
// Check members are unique
|
||||
const memberIds = emailRecipients.models.map(recipient => recipient.get('member_id'));
|
||||
assert.equal(memberIds.length, _.uniq(memberIds).length);
|
||||
|
||||
assert.equal(stubbedSend.callCount, 4);
|
||||
const calls = stubbedSend.getCalls();
|
||||
const deadline = new Date(t0.getTime() + targetDeliveryWindow);
|
||||
|
||||
// Check that the emails were sent with the deliverytime
|
||||
for (const call of calls) {
|
||||
const options = call.args[1];
|
||||
const deliveryTimeString = options['o:deliverytime'];
|
||||
const deliveryTimeDate = new Date(Date.parse(deliveryTimeString));
|
||||
assert.equal(typeof deliveryTimeString, 'string');
|
||||
assert.ok(deliveryTimeDate.getTime() <= deadline.getTime());
|
||||
}
|
||||
configUtils.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Analytics', function () {
|
||||
it('Adds link tracking to all links in a post', async function () {
|
||||
const {emailModel, html, plaintext, recipientData} = await sendEmail(agent);
|
||||
|
@ -221,7 +221,9 @@ class BatchSendingService {
|
||||
async getBatches(email) {
|
||||
logging.info(`Getting batches for email ${email.id}`);
|
||||
|
||||
return await this.#models.EmailBatch.findAll({filter: 'email_id:\'' + email.id + '\''});
|
||||
// findAll returns a bookshelf collection, we want to return a plain array to align with the createBatches method
|
||||
const batches = await this.#models.EmailBatch.findAll({filter: 'email_id:\'' + email.id + '\''});
|
||||
return batches.models;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -354,10 +356,17 @@ class BatchSendingService {
|
||||
|
||||
async sendBatches({email, batches, post, newsletter}) {
|
||||
logging.info(`Sending ${batches.length} batches for email ${email.id}`);
|
||||
|
||||
const deadline = this.getDeliveryDeadline(email);
|
||||
|
||||
if (deadline) {
|
||||
logging.info(`Delivery deadline for email ${email.id} is ${deadline}`);
|
||||
}
|
||||
// Reuse same HTML body if we send an email to the same segment
|
||||
const emailBodyCache = new EmailBodyCache();
|
||||
|
||||
// Calculate deliverytimes for the batches
|
||||
const deliveryTimes = this.calculateDeliveryTimes(email, batches.length);
|
||||
|
||||
// Loop batches and send them via the EmailProvider
|
||||
let succeededCount = 0;
|
||||
const queue = batches.slice();
|
||||
@ -367,7 +376,15 @@ class BatchSendingService {
|
||||
runNext = async () => {
|
||||
const batch = queue.shift();
|
||||
if (batch) {
|
||||
if (await this.sendBatch({email, batch, post, newsletter, emailBodyCache})) {
|
||||
const batchData = {email, batch, post, newsletter, emailBodyCache, deliveryTime: undefined};
|
||||
// Only set a delivery time if we have a deadline and it hasn't past yet
|
||||
if (deadline && deadline.getTime() > Date.now()) {
|
||||
const deliveryTime = deliveryTimes.shift();
|
||||
if (deliveryTime && deliveryTime >= Date.now()) {
|
||||
batchData.deliveryTime = deliveryTime;
|
||||
}
|
||||
}
|
||||
if (await this.sendBatch(batchData)) {
|
||||
succeededCount += 1;
|
||||
}
|
||||
await runNext();
|
||||
@ -391,10 +408,10 @@ class BatchSendingService {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{email: Email, batch: EmailBatch, post: Post, newsletter: Newsletter}} data
|
||||
* @param {{email: Email, batch: EmailBatch, post: Post, newsletter: Newsletter, emailBodyCache: EmailBodyCache, deliveryTime:(Date|undefined) }} data
|
||||
* @returns {Promise<boolean>} True when succeeded, false when failed with an error
|
||||
*/
|
||||
async sendBatch({email, batch: originalBatch, post, newsletter, emailBodyCache}) {
|
||||
async sendBatch({email, batch: originalBatch, post, newsletter, emailBodyCache, deliveryTime}) {
|
||||
logging.info(`Sending batch ${originalBatch.id} for email ${email.id}`);
|
||||
|
||||
// Check the status of the email batch in a 'for update' transaction
|
||||
@ -440,9 +457,10 @@ class BatchSendingService {
|
||||
}, {
|
||||
openTrackingEnabled: !!email.get('track_opens'),
|
||||
clickTrackingEnabled: !!email.get('track_clicks'),
|
||||
deliveryTime,
|
||||
emailBodyCache
|
||||
});
|
||||
}, {...this.#MAILGUN_API_RETRY_CONFIG, description: `Sending email batch ${originalBatch.id}`});
|
||||
}, {...this.#MAILGUN_API_RETRY_CONFIG, description: `Sending email batch ${originalBatch.id} ${deliveryTime ? `with delivery time ${deliveryTime}` : ''}`});
|
||||
succeeded = true;
|
||||
|
||||
await this.retryDb(
|
||||
@ -635,6 +653,51 @@ class BatchSendingService {
|
||||
return await this.retryDb(func, {...options, retryCount: retryCount + 1, sleep: sleep * 2});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sending deadline for an email
|
||||
* Based on the email.created_at timestamp and the configured target delivery window
|
||||
* @param {*} email
|
||||
* @returns Date | undefined
|
||||
*/
|
||||
getDeliveryDeadline(email) {
|
||||
// Return undefined if targetDeliveryWindow is 0 (or less)
|
||||
const targetDeliveryWindow = this.#sendingService.getTargetDeliveryWindow();
|
||||
if (targetDeliveryWindow === undefined || targetDeliveryWindow <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const startTime = email.get('created_at');
|
||||
const deadline = new Date(startTime.getTime() + targetDeliveryWindow);
|
||||
return deadline;
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds deliverytimes to the passed in batches, based on the delivery deadline
|
||||
* @param {Email} email - the email model to be sent
|
||||
* @param {number} numBatches - the number of batches to be sent
|
||||
*/
|
||||
calculateDeliveryTimes(email, numBatches) {
|
||||
const deadline = this.getDeliveryDeadline(email);
|
||||
const now = new Date();
|
||||
// If there is no deadline (target delivery window is not set) or the deadline is in the past, delivery immediately
|
||||
if (!deadline || now >= deadline) {
|
||||
return new Array(numBatches).fill(undefined);
|
||||
} else {
|
||||
const timeToDeadline = deadline.getTime() - now.getTime();
|
||||
const batchDelay = timeToDeadline / numBatches;
|
||||
const deliveryTimes = [];
|
||||
for (let i = 0; i < numBatches; i++) {
|
||||
const delay = batchDelay * i;
|
||||
const deliveryTime = new Date(now.getTime() + delay);
|
||||
deliveryTimes.push(deliveryTime);
|
||||
}
|
||||
return deliveryTimes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BatchSendingService;
|
||||
|
@ -19,6 +19,7 @@ const debug = require('@tryghost/debug')('email-service:mailgun-provider-service
|
||||
* @typedef {object} EmailSendingOptions
|
||||
* @prop {boolean} clickTrackingEnabled
|
||||
* @prop {boolean} openTrackingEnabled
|
||||
* @prop {Date} deliveryTime
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -111,6 +112,10 @@ class MailgunEmailProvider {
|
||||
track_clicks: !!options.clickTrackingEnabled
|
||||
};
|
||||
|
||||
if (options.deliveryTime && options.deliveryTime instanceof Date) {
|
||||
messageData.deliveryTime = options.deliveryTime;
|
||||
}
|
||||
|
||||
// create recipient data for Mailgun using replacement definitions
|
||||
const recipientData = recipients.reduce((acc, recipient) => {
|
||||
acc[recipient.email] = this.#createRecipientData(recipient.replacements);
|
||||
@ -172,6 +177,15 @@ class MailgunEmailProvider {
|
||||
getMaximumRecipients() {
|
||||
return this.#mailgunClient.getBatchSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured delay between batches in milliseconds
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
getTargetDeliveryWindow() {
|
||||
return this.#mailgunClient.getTargetDeliveryWindow();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MailgunEmailProvider;
|
||||
|
@ -15,6 +15,7 @@ const logging = require('@tryghost/logging');
|
||||
* @typedef {object} IEmailProviderService
|
||||
* @prop {(emailData: EmailData, options: EmailSendingOptions) => Promise<EmailProviderSuccessResponse>} send
|
||||
* @prop {() => number} getMaximumRecipients
|
||||
* @prop {() => number} getTargetDeliveryWindow
|
||||
*
|
||||
* @typedef {object} Post
|
||||
* @typedef {object} Newsletter
|
||||
@ -29,6 +30,7 @@ const logging = require('@tryghost/logging');
|
||||
* @typedef {object} EmailSendingOptions
|
||||
* @prop {boolean} clickTrackingEnabled
|
||||
* @prop {boolean} openTrackingEnabled
|
||||
* @prop {Date} deliveryTime
|
||||
* @prop {{get(id: string): EmailBody | null, set(id: string, body: EmailBody): void}} [emailBodyCache]
|
||||
*/
|
||||
|
||||
@ -75,6 +77,15 @@ class SendingService {
|
||||
return this.#emailProvider.getMaximumRecipients();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured target delivery window in seconds
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
getTargetDeliveryWindow() {
|
||||
return this.#emailProvider.getTargetDeliveryWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a given post, rendered for a given newsletter and segment to the members provided in the list
|
||||
* @param {object} data
|
||||
@ -125,7 +136,8 @@ class SendingService {
|
||||
replacementDefinitions: emailBody.replacements
|
||||
}, {
|
||||
clickTrackingEnabled: !!options.clickTrackingEnabled,
|
||||
openTrackingEnabled: !!options.openTrackingEnabled
|
||||
openTrackingEnabled: !!options.openTrackingEnabled,
|
||||
...(options.deliveryTime && {deliveryTime: options.deliveryTime})
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,12 @@ const logging = require('@tryghost/logging');
|
||||
const nql = require('@tryghost/nql');
|
||||
const errors = require('@tryghost/errors');
|
||||
|
||||
// We need a short sleep in some tests to simulate time passing
|
||||
// This way we don't actually add a delay to the tests
|
||||
const simulateSleep = async (ms, clock) => {
|
||||
await Promise.all([sleep(ms), clock.tickAsync(ms)]);
|
||||
};
|
||||
|
||||
describe('Batch Sending Service', function () {
|
||||
let errorLog;
|
||||
|
||||
@ -220,7 +226,12 @@ describe('Batch Sending Service', function () {
|
||||
]
|
||||
});
|
||||
const service = new BatchSendingService({
|
||||
models: {EmailBatch}
|
||||
models: {EmailBatch},
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
const email = createModel({
|
||||
status: 'submitting',
|
||||
@ -265,6 +276,32 @@ describe('Batch Sending Service', function () {
|
||||
const argument = sendBatches.firstCall.args[0];
|
||||
assert.equal(argument.batches, createdBatches);
|
||||
});
|
||||
|
||||
it('passes deadline to sendBatches if target delivery window is set', async function () {
|
||||
const EmailBatch = createModelClass({
|
||||
findAll: []
|
||||
});
|
||||
const service = new BatchSendingService({
|
||||
models: {EmailBatch}
|
||||
});
|
||||
const email = createModel({
|
||||
status: 'submitting',
|
||||
newsletter: createModel({}),
|
||||
post: createModel({})
|
||||
});
|
||||
|
||||
const sendBatches = sinon.stub(service, 'sendBatches').resolves();
|
||||
const createdBatches = [createModel({})];
|
||||
const createBatches = sinon.stub(service, 'createBatches').resolves(createdBatches);
|
||||
const result = await service.sendEmail(email);
|
||||
assert.equal(result, undefined);
|
||||
sinon.assert.calledOnce(sendBatches);
|
||||
sinon.assert.calledOnce(createBatches);
|
||||
|
||||
// Check called with created batch
|
||||
const argument = sendBatches.firstCall.args[0];
|
||||
assert.equal(argument.batches, createdBatches);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBatches', function () {
|
||||
@ -679,9 +716,37 @@ describe('Batch Sending Service', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBatches', function () {
|
||||
it('returns an array of batch models', async function () {
|
||||
const email = createModel({
|
||||
id: '123'
|
||||
});
|
||||
const emailBatches = [
|
||||
createModel({email_id: '123'}),
|
||||
createModel({email_id: '123'})
|
||||
];
|
||||
|
||||
const EmailBatch = createModelClass({
|
||||
findAll: emailBatches
|
||||
});
|
||||
const service = new BatchSendingService({
|
||||
models: {EmailBatch}
|
||||
});
|
||||
const batches = await service.getBatches(email);
|
||||
assert.equal(batches.length, 2);
|
||||
assert.ok(Array.isArray(batches));
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBatches', function () {
|
||||
it('Works for a single batch', async function () {
|
||||
const service = new BatchSendingService({});
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
const sendBatch = sinon.stub(service, 'sendBatch').callsFake(() => {
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
@ -700,13 +765,20 @@ describe('Batch Sending Service', function () {
|
||||
});
|
||||
|
||||
it('Works for more than 2 batches', async function () {
|
||||
const service = new BatchSendingService({});
|
||||
const clock = sinon.useFakeTimers(new Date());
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
let runningCount = 0;
|
||||
let maxRunningCount = 0;
|
||||
const sendBatch = sinon.stub(service, 'sendBatch').callsFake(async () => {
|
||||
runningCount += 1;
|
||||
maxRunningCount = Math.max(maxRunningCount, runningCount);
|
||||
await sleep(5);
|
||||
await simulateSleep(5, clock);
|
||||
runningCount -= 1;
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
@ -721,16 +793,142 @@ describe('Batch Sending Service', function () {
|
||||
const sendBatches = sendBatch.getCalls().map(call => call.args[0].batch);
|
||||
assert.deepEqual(sendBatches, batches);
|
||||
assert.equal(maxRunningCount, 2);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('Works with a target delivery window set', async function () {
|
||||
// Set some parameters for sending the batches
|
||||
const now = new Date();
|
||||
const clock = sinon.useFakeTimers(now);
|
||||
const targetDeliveryWindow = 300000; // 5 minutes
|
||||
const expectedDeadline = new Date(now.getTime() + targetDeliveryWindow);
|
||||
const numBatches = 10;
|
||||
const expectedBatchDelay = targetDeliveryWindow / numBatches;
|
||||
const email = createModel({
|
||||
created_at: now
|
||||
});
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return targetDeliveryWindow;
|
||||
}
|
||||
}
|
||||
});
|
||||
let runningCount = 0;
|
||||
let maxRunningCount = 0;
|
||||
// Stub the sendBatch method to inspect the delivery times for each batch
|
||||
const sendBatch = sinon.stub(service, 'sendBatch').callsFake(async () => {
|
||||
runningCount += 1;
|
||||
maxRunningCount = Math.max(maxRunningCount, runningCount);
|
||||
await simulateSleep(5, clock);
|
||||
runningCount -= 1;
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
// Create the batches
|
||||
const batches = new Array(numBatches).fill(0).map(() => createModel({}));
|
||||
// Invoke the sendBatches method to send the batches
|
||||
await service.sendBatches({
|
||||
email,
|
||||
batches,
|
||||
post: createModel({}),
|
||||
newsletter: createModel({})
|
||||
});
|
||||
// Assert that the sendBatch method was called the correct number of times
|
||||
sinon.assert.callCount(sendBatch, numBatches);
|
||||
// Get the batches there were sent from the sendBatch method calls
|
||||
const sendBatches = sendBatch.getCalls().map(call => call.args[0].batch);
|
||||
// Get the delivery times for each batch from the sendBatch method calls
|
||||
const deliveryTimes = sendBatch.getCalls().map(call => call.args[0].deliveryTime);
|
||||
|
||||
// Make sure all delivery times are valid dates, and are before the deadline
|
||||
deliveryTimes.forEach((time) => {
|
||||
assert.ok(time instanceof Date);
|
||||
assert.ok(!isNaN(time.getTime()));
|
||||
assert.ok(time <= expectedDeadline);
|
||||
});
|
||||
// Make sure the delivery times are evenly spaced out, within a reasonable range
|
||||
// Sort the delivery times in ascending order (just in case they're not in order)
|
||||
deliveryTimes.sort((a, b) => a.getTime() - b.getTime());
|
||||
const differences = [];
|
||||
for (let i = 1; i < deliveryTimes.length; i++) {
|
||||
differences.push(deliveryTimes[i].getTime() - deliveryTimes[i - 1].getTime());
|
||||
}
|
||||
// Make sure the differences are within a few ms of the expected batch delay
|
||||
differences.forEach((difference) => {
|
||||
assert.ok(difference >= expectedBatchDelay - 100, `Difference ${difference} is less than expected ${expectedBatchDelay}`);
|
||||
assert.ok(difference <= expectedBatchDelay + 100, `Difference ${difference} is greater than expected ${expectedBatchDelay}`);
|
||||
});
|
||||
assert.deepEqual(sendBatches, batches);
|
||||
assert.equal(maxRunningCount, 2);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('omits deliverytime if deadline is in the past', async function () {
|
||||
// Set some parameters for sending the batches
|
||||
const now = new Date();
|
||||
const clock = sinon.useFakeTimers(now);
|
||||
const targetDeliveryWindow = 300000; // 5 minutes
|
||||
const numBatches = 10;
|
||||
const email = createModel({
|
||||
created_at: now
|
||||
});
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return targetDeliveryWindow;
|
||||
}
|
||||
}
|
||||
});
|
||||
let runningCount = 0;
|
||||
let maxRunningCount = 0;
|
||||
// Stub the sendBatch method to inspect the delivery times for each batch
|
||||
const sendBatch = sinon.stub(service, 'sendBatch').callsFake(async () => {
|
||||
runningCount += 1;
|
||||
maxRunningCount = Math.max(maxRunningCount, runningCount);
|
||||
await simulateSleep(5, clock);
|
||||
runningCount -= 1;
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
// Create the batches
|
||||
const batches = new Array(numBatches).fill(0).map(() => createModel({}));
|
||||
// Invoke the sendBatches method to send the batches
|
||||
clock.tick(1000000);
|
||||
await service.sendBatches({
|
||||
email,
|
||||
batches,
|
||||
post: createModel({}),
|
||||
newsletter: createModel({})
|
||||
});
|
||||
// Assert that the sendBatch method was called the correct number of times
|
||||
sinon.assert.callCount(sendBatch, numBatches);
|
||||
// Get the batches there were sent from the sendBatch method calls
|
||||
const sendBatches = sendBatch.getCalls().map(call => call.args[0].batch);
|
||||
// Get the delivery times for each batch from the sendBatch method calls
|
||||
const deliveryTimes = sendBatch.getCalls().map(call => call.args[0].deliveryTime);
|
||||
// Assert that the deliverytime is not set, since we're past the deadline
|
||||
deliveryTimes.forEach((time) => {
|
||||
assert.equal(time, undefined);
|
||||
});
|
||||
assert.deepEqual(sendBatches, batches);
|
||||
assert.equal(maxRunningCount, 2);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('Throws error if all batches fail', async function () {
|
||||
const service = new BatchSendingService({});
|
||||
const clock = sinon.useFakeTimers(new Date());
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
let runningCount = 0;
|
||||
let maxRunningCount = 0;
|
||||
const sendBatch = sinon.stub(service, 'sendBatch').callsFake(async () => {
|
||||
runningCount += 1;
|
||||
maxRunningCount = Math.max(maxRunningCount, runningCount);
|
||||
await sleep(5);
|
||||
await simulateSleep(5, clock);
|
||||
runningCount -= 1;
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
@ -745,17 +943,25 @@ describe('Batch Sending Service', function () {
|
||||
const sendBatches = sendBatch.getCalls().map(call => call.args[0].batch);
|
||||
assert.deepEqual(sendBatches, batches);
|
||||
assert.equal(maxRunningCount, 2);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('Throws error if a single batch fails', async function () {
|
||||
const service = new BatchSendingService({});
|
||||
const clock = sinon.useFakeTimers(new Date());
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
let runningCount = 0;
|
||||
let maxRunningCount = 0;
|
||||
let callCount = 0;
|
||||
const sendBatch = sinon.stub(service, 'sendBatch').callsFake(async () => {
|
||||
runningCount += 1;
|
||||
maxRunningCount = Math.max(maxRunningCount, runningCount);
|
||||
await sleep(5);
|
||||
await simulateSleep(5, clock);
|
||||
runningCount -= 1;
|
||||
callCount += 1;
|
||||
return Promise.resolve(callCount === 12 ? false : true);
|
||||
@ -778,6 +984,7 @@ describe('Batch Sending Service', function () {
|
||||
const sendBatches = sendBatch.getCalls().map(call => call.args[0].batch);
|
||||
assert.deepEqual(sendBatches, batches);
|
||||
assert.equal(maxRunningCount, 2);
|
||||
clock.restore();
|
||||
});
|
||||
});
|
||||
|
||||
@ -879,6 +1086,50 @@ describe('Batch Sending Service', function () {
|
||||
assert.equal(members.length, 2);
|
||||
});
|
||||
|
||||
it('Does send with a deliverytime', async function () {
|
||||
const EmailBatch = createModelClass({
|
||||
findOne: {
|
||||
status: 'pending',
|
||||
member_segment: null
|
||||
}
|
||||
});
|
||||
const sendingService = {
|
||||
send: sinon.stub().resolves({id: 'providerid@example.com'}),
|
||||
getMaximumRecipients: () => 5
|
||||
};
|
||||
|
||||
const findOne = sinon.spy(EmailBatch, 'findOne');
|
||||
const service = new BatchSendingService({
|
||||
models: {EmailBatch, EmailRecipient},
|
||||
sendingService
|
||||
});
|
||||
|
||||
const inputDeliveryTime = new Date(Date.now() + 10000);
|
||||
|
||||
const result = await service.sendBatch({
|
||||
email: createModel({}),
|
||||
batch: createModel({}),
|
||||
post: createModel({}),
|
||||
newsletter: createModel({}),
|
||||
deliveryTime: inputDeliveryTime
|
||||
});
|
||||
|
||||
assert.equal(result, true);
|
||||
sinon.assert.notCalled(errorLog);
|
||||
sinon.assert.calledOnce(sendingService.send);
|
||||
|
||||
sinon.assert.calledOnce(findOne);
|
||||
const batch = await findOne.firstCall.returnValue;
|
||||
assert.equal(batch.get('status'), 'submitted');
|
||||
assert.equal(batch.get('provider_id'), 'providerid@example.com');
|
||||
|
||||
const {members} = sendingService.send.firstCall.args[0];
|
||||
assert.equal(members.length, 2);
|
||||
|
||||
const {deliveryTime: outputDeliveryTime} = sendingService.send.firstCall.args[1];
|
||||
assert.equal(inputDeliveryTime, outputDeliveryTime);
|
||||
});
|
||||
|
||||
it('Does save error', async function () {
|
||||
const EmailBatch = createModelClass({
|
||||
findOne: {
|
||||
@ -1314,6 +1565,7 @@ describe('Batch Sending Service', function () {
|
||||
});
|
||||
await assert.rejects(result, /Test error/);
|
||||
assert.equal(callCount, 3);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('Stops after maxTime', async function () {
|
||||
@ -1329,6 +1581,7 @@ describe('Batch Sending Service', function () {
|
||||
});
|
||||
await assert.rejects(result, /Test error/);
|
||||
assert.equal(callCount, 3);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('Resolves after maxTime', async function () {
|
||||
@ -1348,6 +1601,7 @@ describe('Batch Sending Service', function () {
|
||||
});
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(callCount, 3);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('Resolves with stopAfterDate', async function () {
|
||||
@ -1366,6 +1620,145 @@ describe('Batch Sending Service', function () {
|
||||
});
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(callCount, 4);
|
||||
clock.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeliveryDeadline', function () {
|
||||
it('returns undefined if the targetDeliveryWindow is not set', async function () {
|
||||
const email = createModel({
|
||||
created_at: new Date()
|
||||
});
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
const result = service.getDeliveryDeadline(email);
|
||||
assert.equal(result, undefined, 'getDeliveryDeadline should return undefined if target delivery window is <=0');
|
||||
});
|
||||
|
||||
it('returns undefined if the email.created_at is not set', async function () {
|
||||
const email = createModel({});
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return 300000; // 5 minutes
|
||||
}
|
||||
}
|
||||
});
|
||||
const result = service.getDeliveryDeadline(email);
|
||||
assert.equal(result, undefined, 'getDeliveryDeadline should return undefined if email.created_at is not set');
|
||||
});
|
||||
|
||||
it('returns undefined if the email.created_at is not a valid date', async function () {
|
||||
const email = createModel({
|
||||
created_at: 'not a date'
|
||||
});
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return 300000; // 5 minutes
|
||||
}
|
||||
}
|
||||
});
|
||||
const result = service.getDeliveryDeadline(email);
|
||||
assert.equal(result, undefined, 'getDeliveryDeadline should return undefined if email.created_at is not a valid date');
|
||||
});
|
||||
|
||||
it('returns the correct deadline if targetDeliveryWindow is set', async function () {
|
||||
const TARGET_DELIVERY_WINDOW = 300000; // 5 minutes
|
||||
const emailCreatedAt = new Date();
|
||||
const email = createModel({
|
||||
created_at: emailCreatedAt
|
||||
});
|
||||
const expectedDeadline = new Date(emailCreatedAt.getTime() + TARGET_DELIVERY_WINDOW);
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return TARGET_DELIVERY_WINDOW;
|
||||
}
|
||||
}
|
||||
});
|
||||
const result = service.getDeliveryDeadline(email);
|
||||
assert.equal(typeof result, 'object');
|
||||
assert.equal(result.toUTCString(), expectedDeadline.toUTCString(), 'The delivery deadline should be 5 minutes after the email.created_at timestamp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDeliveryTimes', function () {
|
||||
it('does add the correct deliverytimes if we are not past the deadline yet', async function () {
|
||||
const now = new Date();
|
||||
const clock = sinon.useFakeTimers(now);
|
||||
const TARGET_DELIVERY_WINDOW = 300000; // 5 minutes
|
||||
const email = createModel({
|
||||
created_at: now
|
||||
});
|
||||
const numBatches = 5;
|
||||
const delay = TARGET_DELIVERY_WINDOW / numBatches;
|
||||
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return TARGET_DELIVERY_WINDOW;
|
||||
}
|
||||
}
|
||||
});
|
||||
const expectedResult = [
|
||||
new Date(now.getTime() + (delay * 0)),
|
||||
new Date(now.getTime() + (delay * 1)),
|
||||
new Date(now.getTime() + (delay * 2)),
|
||||
new Date(now.getTime() + (delay * 3)),
|
||||
new Date(now.getTime() + (delay * 4))
|
||||
];
|
||||
const result = service.calculateDeliveryTimes(email, numBatches);
|
||||
assert.deepEqual(result, expectedResult);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('returns an array of undefined values if we are past the deadline', async function () {
|
||||
const now = new Date();
|
||||
const clock = sinon.useFakeTimers(now);
|
||||
const TARGET_DELIVERY_WINDOW = 300000; // 5 minutes
|
||||
const email = createModel({
|
||||
created_at: now
|
||||
});
|
||||
const numBatches = 5;
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return TARGET_DELIVERY_WINDOW;
|
||||
}
|
||||
}
|
||||
});
|
||||
const expectedResult = [
|
||||
undefined, undefined, undefined, undefined, undefined
|
||||
];
|
||||
// Advance time past the deadline
|
||||
clock.tick(1000000);
|
||||
const result = service.calculateDeliveryTimes(email, numBatches);
|
||||
assert.deepEqual(result, expectedResult);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('returns an array of undefined values if the target delivery window is not set', async function () {
|
||||
const TARGET_DELIVERY_WINDOW = 0;
|
||||
const email = createModel({});
|
||||
const numBatches = 5;
|
||||
const service = new BatchSendingService({
|
||||
sendingService: {
|
||||
getTargetDeliveryWindow() {
|
||||
return TARGET_DELIVERY_WINDOW;
|
||||
}
|
||||
}
|
||||
});
|
||||
const expectedResult = [
|
||||
undefined, undefined, undefined, undefined, undefined
|
||||
];
|
||||
const result = service.calculateDeliveryTimes(email, numBatches);
|
||||
assert.deepEqual(result, expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -27,6 +27,8 @@ describe('Mailgun Email Provider', function () {
|
||||
mailgunClient,
|
||||
errorHandler: () => {}
|
||||
});
|
||||
|
||||
const deliveryTime = new Date();
|
||||
|
||||
const response = await mailgunEmailProvider.send({
|
||||
subject: 'Hi',
|
||||
@ -56,7 +58,8 @@ describe('Mailgun Email Provider', function () {
|
||||
]
|
||||
}, {
|
||||
clickTrackingEnabled: true,
|
||||
openTrackingEnabled: true
|
||||
openTrackingEnabled: true,
|
||||
deliveryTime
|
||||
});
|
||||
should(response.id).eql('provider-123');
|
||||
should(sendStub.calledOnce).be.true();
|
||||
@ -68,6 +71,7 @@ describe('Mailgun Email Provider', function () {
|
||||
from: 'ghost@example.com',
|
||||
replyTo: 'ghost@example.com',
|
||||
id: '123',
|
||||
deliveryTime,
|
||||
track_opens: true,
|
||||
track_clicks: true
|
||||
},
|
||||
@ -242,4 +246,23 @@ describe('Mailgun Email Provider', function () {
|
||||
assert.equal(provider.getMaximumRecipients(), 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTargetDeliveryWindow', function () {
|
||||
let mailgunClient;
|
||||
let getTargetDeliveryWindowStub;
|
||||
|
||||
it('returns the configured target delivery window', function () {
|
||||
getTargetDeliveryWindowStub = sinon.stub().returns(0);
|
||||
|
||||
mailgunClient = {
|
||||
getTargetDeliveryWindow: getTargetDeliveryWindowStub
|
||||
};
|
||||
|
||||
const provider = new MailgunEmailProvider({
|
||||
mailgunClient,
|
||||
errorHandler: () => {}
|
||||
});
|
||||
assert.equal(provider.getTargetDeliveryWindow(), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -53,6 +53,8 @@ describe('Sending service', function () {
|
||||
emailProvider
|
||||
});
|
||||
|
||||
const deliveryTime = new Date();
|
||||
|
||||
const response = await sendingService.send({
|
||||
post: {},
|
||||
newsletter: {},
|
||||
@ -66,7 +68,68 @@ describe('Sending service', function () {
|
||||
]
|
||||
}, {
|
||||
clickTrackingEnabled: true,
|
||||
openTrackingEnabled: true
|
||||
openTrackingEnabled: true,
|
||||
deliveryTime
|
||||
});
|
||||
assert.equal(response.id, 'provider-123');
|
||||
sinon.assert.calledOnce(sendStub);
|
||||
assert(sendStub.calledWith(
|
||||
{
|
||||
subject: 'Hi',
|
||||
from: 'ghost@example.com',
|
||||
replyTo: 'ghost+reply@example.com',
|
||||
html: '<html><body>Hi {{name}}</body></html>',
|
||||
plaintext: 'Hi',
|
||||
emailId: '123',
|
||||
replacementDefinitions: [
|
||||
{
|
||||
id: 'name',
|
||||
token: '{{name}}',
|
||||
getValue: sinon.match.func
|
||||
}
|
||||
],
|
||||
recipients: [
|
||||
{
|
||||
email: 'member@example.com',
|
||||
replacements: [{
|
||||
id: 'name',
|
||||
token: '{{name}}',
|
||||
value: 'John'
|
||||
}]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
clickTrackingEnabled: true,
|
||||
openTrackingEnabled: true,
|
||||
deliveryTime
|
||||
}
|
||||
));
|
||||
});
|
||||
|
||||
it('calls mailgun client without the deliverytime if it is not defined', async function () {
|
||||
const sendingService = new SendingService({
|
||||
emailRenderer,
|
||||
emailProvider
|
||||
});
|
||||
|
||||
const deliveryTime = undefined;
|
||||
|
||||
const response = await sendingService.send({
|
||||
post: {},
|
||||
newsletter: {},
|
||||
segment: null,
|
||||
emailId: '123',
|
||||
members: [
|
||||
{
|
||||
email: 'member@example.com',
|
||||
name: 'John'
|
||||
}
|
||||
]
|
||||
}, {
|
||||
clickTrackingEnabled: true,
|
||||
openTrackingEnabled: true,
|
||||
deliveryTime
|
||||
});
|
||||
assert.equal(response.id, 'provider-123');
|
||||
sinon.assert.calledOnce(sendStub);
|
||||
@ -373,4 +436,17 @@ describe('Sending service', function () {
|
||||
sinon.assert.calledOnce(emailProvider.getMaximumRecipients);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTargetDeliveryWindow', function () {
|
||||
it('returns the target delivery window of the email provider', function () {
|
||||
const emailProvider = {
|
||||
getTargetDeliveryWindow: sinon.stub().returns(0)
|
||||
};
|
||||
const sendingService = new SendingService({
|
||||
emailProvider
|
||||
});
|
||||
assert.equal(sendingService.getTargetDeliveryWindow(), 0);
|
||||
sinon.assert.calledOnce(emailProvider.getTargetDeliveryWindow);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -97,6 +97,11 @@ module.exports = class MailgunClient {
|
||||
messageData['o:tracking-opens'] = true;
|
||||
}
|
||||
|
||||
// set the delivery time if specified
|
||||
if (message.deliveryTime && message.deliveryTime instanceof Date) {
|
||||
messageData['o:deliverytime'] = message.deliveryTime.toUTCString();
|
||||
}
|
||||
|
||||
const mailgunConfig = this.#getConfig();
|
||||
startTime = Date.now();
|
||||
const response = await mailgunInstance.messages.create(mailgunConfig.domain, messageData);
|
||||
@ -339,4 +344,21 @@ module.exports = class MailgunClient {
|
||||
getBatchSize() {
|
||||
return this.#config.get('bulkEmail')?.batchSize ?? this.DEFAULT_BATCH_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured target delivery window in seconds
|
||||
* Ghost will attempt to deliver emails evenly distributed over this window
|
||||
*
|
||||
* Defaults to 0 (no delay) if not set
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
getTargetDeliveryWindow() {
|
||||
const targetDeliveryWindow = this.#config.get('bulkEmail')?.targetDeliveryWindow;
|
||||
// If targetDeliveryWindow is not set or is not a positive integer, return 0
|
||||
if (targetDeliveryWindow === undefined || !Number.isInteger(parseInt(targetDeliveryWindow)) || parseInt(targetDeliveryWindow) < 0) {
|
||||
return 0;
|
||||
}
|
||||
return parseInt(targetDeliveryWindow);
|
||||
}
|
||||
};
|
||||
|
4
ghost/mailgun-client/test/fixtures/send-success.json
vendored
Normal file
4
ghost/mailgun-client/test/fixtures/send-success.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "message-id",
|
||||
"message": "Queued. Thank you."
|
||||
}
|
@ -58,6 +58,67 @@ describe('MailgunClient', function () {
|
||||
assert(typeof mailgunClient.getBatchSize() === 'number');
|
||||
});
|
||||
|
||||
it('exports a number for configurable target delivery window', function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000,
|
||||
targetDeliveryWindow: 300
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
assert.equal(mailgunClient.getTargetDeliveryWindow(), 300);
|
||||
});
|
||||
|
||||
it('exports a number — 0 — for configurable target delivery window if not set', function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
assert.equal(mailgunClient.getTargetDeliveryWindow(), 0);
|
||||
});
|
||||
|
||||
it('exports a number - 0 - for configurable target delivery window if an invalid value is set', function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000,
|
||||
targetDeliveryWindow: 'invalid'
|
||||
});
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
assert.equal(mailgunClient.getTargetDeliveryWindow(), 0);
|
||||
});
|
||||
|
||||
it('exports a number - 0 - for configurable target delivery window if a negative value is set', function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000,
|
||||
targetDeliveryWindow: -3000
|
||||
});
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
assert.equal(mailgunClient.getTargetDeliveryWindow(), 0);
|
||||
});
|
||||
|
||||
it('can connect via config', function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
@ -165,6 +226,450 @@ describe('MailgunClient', function () {
|
||||
|
||||
assert.equal(response, null);
|
||||
});
|
||||
|
||||
it('sends a basic email', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content'
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="subject"[^]*Test Subject/m,
|
||||
/form-data; name="from"[^]*from@example.com/m,
|
||||
/form-data; name="h:Reply-To"[^]*replyTo@example.com/m,
|
||||
/form-data; name="html"[^]*<p>Test Content<\/p>/m,
|
||||
/form-data; name="text"[^]*Test Content/m,
|
||||
/form-data; name="to"[^]*test@example.com/m,
|
||||
/form-data; name="recipient-variables"[^]*\{"test@example.com":\{"name":"Test User"\}\}/m,
|
||||
/form-data; name="o:tag"[^]*bulk-email/m,
|
||||
/form-data; name="o:tag"[^]*ghost-email/m
|
||||
];
|
||||
return regexList.every(regex => regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
|
||||
it('throws an error if sending to more than the batch size', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 2
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content'
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User'
|
||||
},
|
||||
'test+1@example.com': {
|
||||
name: 'Test User'
|
||||
},
|
||||
'test+2@example.com': {
|
||||
name: 'Test User'
|
||||
}
|
||||
};
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
|
||||
await assert.rejects(mailgunClient.send(message, recipientData, []));
|
||||
});
|
||||
|
||||
it('sends an email with list unsubscribe headers', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content'
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User',
|
||||
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||
list_unsubscribe: 'https://example.com/unsubscribe'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="h:List-Unsubscribe"[^]*<%recipient.list_unsubscribe%>, <%tag_unsubscribe_email%>/m,
|
||||
/form-data; name="h:List-Unsubscribe-Post"[^]*List-Unsubscribe=One-Click/m
|
||||
];
|
||||
return regexList.every(regex => regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
|
||||
it('sends an email with email id', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content',
|
||||
id: 'email-id'
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User',
|
||||
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||
list_unsubscribe: 'https://example.com/unsubscribe'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="v:email-id"[^]*email-id/m
|
||||
];
|
||||
return regexList.every(regex => regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
|
||||
it('sends an email in test mode', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3',
|
||||
testmode: true
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content'
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User',
|
||||
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||
list_unsubscribe: 'https://example.com/unsubscribe'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="o:testmode"[^]*yes/m
|
||||
];
|
||||
return regexList.every(regex => regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
|
||||
it('sends an email with a custom tag', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3',
|
||||
tag: 'custom-tag'
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content'
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User',
|
||||
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||
list_unsubscribe: 'https://example.com/unsubscribe'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="o:tag"[^]*custom-tag/m
|
||||
];
|
||||
return regexList.every(regex => regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
|
||||
it('sends an email with tracking opens enabled', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content',
|
||||
track_opens: true
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User',
|
||||
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||
list_unsubscribe: 'https://example.com/unsubscribe'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="o:tracking-opens"[^]*yes/m
|
||||
];
|
||||
return regexList.every(regex => regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
|
||||
it('sends an email with delivery time', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content',
|
||||
deliveryTime: new Date('2021-01-01T00:00:00Z')
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User',
|
||||
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||
list_unsubscribe: 'https://example.com/unsubscribe'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="o:deliverytime"[^]*Fri, 01 Jan 2021 00:00:00 GMT/m
|
||||
];
|
||||
return regexList.every(regex => regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
|
||||
it('omits the deliverytime if it is not provided', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3',
|
||||
testmode: true
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content'
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User',
|
||||
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||
list_unsubscribe: 'https://example.com/unsubscribe'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="o:deliverytime"[^]*/m
|
||||
];
|
||||
return regexList.every(regex => !regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
|
||||
it('omits the deliverytime if it is not a valid date', async function () {
|
||||
const configStub = sinon.stub(config, 'get');
|
||||
configStub.withArgs('bulkEmail').returns({
|
||||
mailgun: {
|
||||
apiKey: 'apiKey',
|
||||
domain: 'domain.com',
|
||||
baseUrl: 'https://api.mailgun.net/v3'
|
||||
},
|
||||
batchSize: 1000
|
||||
});
|
||||
const message = {
|
||||
subject: 'Test Subject',
|
||||
from: 'from@example.com',
|
||||
replyTo: 'replyTo@example.com',
|
||||
html: '<p>Test Content</p>',
|
||||
plaintext: 'Test Content',
|
||||
deliveryTime: 'not a date'
|
||||
};
|
||||
const recipientData = {
|
||||
'test@example.com': {
|
||||
name: 'Test User',
|
||||
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||
list_unsubscribe: 'https://example.com/unsubscribe'
|
||||
}
|
||||
};
|
||||
// Request body is multipart/form-data, so we need to check the body manually with some regex
|
||||
// We can't use nock's JSON body matching because it doesn't support multipart/form-data
|
||||
const sendMock = nock('https://api.mailgun.net')
|
||||
// .post('/v3/domain.com/messages', /form-data; name="subject"[^]*Test Subject/m)
|
||||
.post('/v3/domain.com/messages', function (body) {
|
||||
const regexList = [
|
||||
/form-data; name="o:deliverytime"[^]*/m
|
||||
];
|
||||
return regexList.every(regex => !regex.test(body));
|
||||
})
|
||||
.replyWithFile(200, `${__dirname}/fixtures/send-success.json`, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const mailgunClient = new MailgunClient({config, settings});
|
||||
const response = await mailgunClient.send(message, recipientData, []);
|
||||
assert(response.id === 'message-id');
|
||||
assert(sendMock.isDone());
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEvents()', function () {
|
||||
|
Loading…
Reference in New Issue
Block a user