diff --git a/ghost/core/test/integration/services/email-service/batch-sending.test.js b/ghost/core/test/integration/services/email-service/batch-sending.test.js index 9e3fc96f47..27cdc9b142 100644 --- a/ghost/core/test/integration/services/email-service/batch-sending.test.js +++ b/ghost/core/test/integration/services/email-service/batch-sending.test.js @@ -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); diff --git a/ghost/email-service/lib/BatchSendingService.js b/ghost/email-service/lib/BatchSendingService.js index 15f6661067..aba7d2fb0e 100644 --- a/ghost/email-service/lib/BatchSendingService.js +++ b/ghost/email-service/lib/BatchSendingService.js @@ -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} 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; diff --git a/ghost/email-service/lib/MailgunEmailProvider.js b/ghost/email-service/lib/MailgunEmailProvider.js index 622a712b4f..85310a30d1 100644 --- a/ghost/email-service/lib/MailgunEmailProvider.js +++ b/ghost/email-service/lib/MailgunEmailProvider.js @@ -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; diff --git a/ghost/email-service/lib/SendingService.js b/ghost/email-service/lib/SendingService.js index ec9ed90b13..50e519a309 100644 --- a/ghost/email-service/lib/SendingService.js +++ b/ghost/email-service/lib/SendingService.js @@ -15,6 +15,7 @@ const logging = require('@tryghost/logging'); * @typedef {object} IEmailProviderService * @prop {(emailData: EmailData, options: EmailSendingOptions) => Promise} 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}) }); } diff --git a/ghost/email-service/test/batch-sending-service.test.js b/ghost/email-service/test/batch-sending-service.test.js index b16634a1a4..9f1f61cb9d 100644 --- a/ghost/email-service/test/batch-sending-service.test.js +++ b/ghost/email-service/test/batch-sending-service.test.js @@ -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); }); }); }); diff --git a/ghost/email-service/test/mailgun-email-provider.test.js b/ghost/email-service/test/mailgun-email-provider.test.js index 0001ea7a84..79b2a75d2f 100644 --- a/ghost/email-service/test/mailgun-email-provider.test.js +++ b/ghost/email-service/test/mailgun-email-provider.test.js @@ -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); + }); + }); }); diff --git a/ghost/email-service/test/sending-service.test.js b/ghost/email-service/test/sending-service.test.js index 4da317a525..121cdbb879 100644 --- a/ghost/email-service/test/sending-service.test.js +++ b/ghost/email-service/test/sending-service.test.js @@ -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: 'Hi {{name}}', + 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); + }); + }); }); diff --git a/ghost/mailgun-client/lib/MailgunClient.js b/ghost/mailgun-client/lib/MailgunClient.js index 2dbf485c90..591d149442 100644 --- a/ghost/mailgun-client/lib/MailgunClient.js +++ b/ghost/mailgun-client/lib/MailgunClient.js @@ -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); + } }; diff --git a/ghost/mailgun-client/test/fixtures/send-success.json b/ghost/mailgun-client/test/fixtures/send-success.json new file mode 100644 index 0000000000..471b883275 --- /dev/null +++ b/ghost/mailgun-client/test/fixtures/send-success.json @@ -0,0 +1,4 @@ +{ + "id": "message-id", + "message": "Queued. Thank you." +} \ No newline at end of file diff --git a/ghost/mailgun-client/test/mailgun-client.test.js b/ghost/mailgun-client/test/mailgun-client.test.js index 2dd4546c27..18981fe17d 100644 --- a/ghost/mailgun-client/test/mailgun-client.test.js +++ b/ghost/mailgun-client/test/mailgun-client.test.js @@ -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: '

Test Content

', + 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"[^]*

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: '

Test Content

', + 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: '

Test Content

', + 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: '

Test Content

', + 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: '

Test Content

', + 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: '

Test Content

', + 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: '

Test Content

', + 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: '

Test Content

', + 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: '

Test Content

', + 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: '

Test Content

', + 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 () {