const _ = require('lodash'); const debug = require('@tryghost/debug'); const logging = require('@tryghost/logging'); const metrics = require('@tryghost/metrics'); const errors = require('@tryghost/errors'); module.exports = class MailgunClient { #config; #settings; static DEFAULT_BATCH_SIZE = 1000; constructor({config, settings}) { this.#config = config; this.#settings = settings; } /** * Creates the data payload and sends to Mailgun * * @param {Object} message * @param {Object} recipientData * @param {Array} replacements * * recipientData format: * { * 'test@example.com': { * name: 'Test User', * unsubscribe_url: 'https://example.com/unsub/me', * list_unsubscribe: 'https://example.com/unsub/me' * } * } */ async send(message, recipientData, replacements) { const mailgunInstance = this.getInstance(); if (!mailgunInstance) { logging.warn(`Mailgun is not configured`); return null; } const batchSize = this.getBatchSize(); if (Object.keys(recipientData).length > batchSize) { throw new errors.IncorrectUsageError({ message: `Mailgun only supports sending to ${batchSize} recipients at a time` }); } let messageData = {}; let startTime; try { const bulkEmailConfig = this.#config.get('bulkEmail'); const messageContent = _.pick(message, 'subject', 'html', 'plaintext'); // update content to use Mailgun variable syntax for replacements replacements.forEach((replacement) => { messageContent[replacement.format] = messageContent[replacement.format].replace( replacement.regexp, `%recipient.${replacement.id}%` ); }); messageData = { to: Object.keys(recipientData), from: message.from, 'h:Reply-To': message.replyTo || message.reply_to, subject: messageContent.subject, html: messageContent.html, text: messageContent.plaintext, 'recipient-variables': JSON.stringify(recipientData) }; // Do we have a custom List-Unsubscribe header set? // (we need a variable for this, as this is a per-email setting) if (Object.keys(recipientData)[0] && recipientData[Object.keys(recipientData)[0]].list_unsubscribe) { messageData['h:List-Unsubscribe'] = '<%recipient.list_unsubscribe%>, <%tag_unsubscribe_email%>'; messageData['h:List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; } // add a reference to the original email record for easier mapping of mailgun event -> email if (message.id) { messageData['v:email-id'] = message.id; } const tags = ['bulk-email', 'ghost-email']; if (bulkEmailConfig?.mailgun?.tag) { tags.push(bulkEmailConfig.mailgun.tag); } messageData['o:tag'] = tags; if (bulkEmailConfig?.mailgun?.testmode) { messageData['o:testmode'] = true; } // enable tracking if turned on for this email if (message.track_opens) { messageData['o:tracking-opens'] = true; } const mailgunConfig = this.#getConfig(); startTime = Date.now(); const response = await mailgunInstance.messages.create(mailgunConfig.domain, messageData); metrics.metric('mailgun-send-mail', { value: Date.now() - startTime, statusCode: 200 }); return { id: response.id }; } catch (error) { logging.error(error); metrics.metric('mailgun-send-mail', { value: Date.now() - startTime, statusCode: error.status }); return Promise.reject({error, messageData}); } } /** * @param {import('mailgun.js').default} mailgunInstance * @param {Object} mailgunConfig * @param {Object} mailgunOptions */ async getEventsFromMailgun(mailgunInstance, mailgunConfig, mailgunOptions) { const startTime = Date.now(); try { const page = await mailgunInstance.events.get(mailgunConfig.domain, mailgunOptions); metrics.metric('mailgun-get-events', { value: Date.now() - startTime, statusCode: 200 }); return page; } catch (error) { metrics.metric('mailgun-get-events', { value: Date.now() - startTime, statusCode: error.status }); throw error; } } /** * Fetches events from Mailgun * @param {Object} mailgunOptions * @param {Function} batchHandler * @param {Object} options * @param {Number} options.maxEvents Not a strict maximum. We stop fetching after we reached the maximum AND received at least one event after begin (not equal) to prevent deadlocks. * @returns {Promise} */ async fetchEvents(mailgunOptions, batchHandler, {maxEvents = Infinity} = {}) { const mailgunInstance = this.getInstance(); if (!mailgunInstance) { logging.warn(`Mailgun is not configured`); return; } debug(`fetchEvents: starting fetching first events page`); const mailgunConfig = this.#getConfig(); const startDate = new Date(); try { let page = await this.getEventsFromMailgun(mailgunInstance, mailgunConfig, mailgunOptions); // By limiting the processed events to ones created before this job started we cancel early ready for the next job run. // Avoids chance of events being missed in long job runs due to mailgun's eventual-consistency creating events outside of our 30min sliding re-check window let events = (page?.items?.map(this.normalizeEvent) || []).filter(e => !!e && e.timestamp <= startDate); debug(`fetchEvents: finished fetching first page with ${events.length} events`); let eventCount = 0; const beginTimestamp = mailgunOptions.begin ? Math.ceil(mailgunOptions.begin * 1000) : undefined; // ceil here if we have rounding errors while (events.length !== 0) { await batchHandler(events); eventCount += events.length; if (eventCount >= maxEvents && (!beginTimestamp || !events[events.length - 1].timestamp || (events[events.length - 1].timestamp.getTime() > beginTimestamp))) { break; } const nextPageId = page.pages.next.page; debug(`fetchEvents: starting fetching next page ${nextPageId}`); page = await this.getEventsFromMailgun(mailgunInstance, mailgunConfig, { page: nextPageId, ...mailgunOptions }); // We need to cap events at the time we started fetching them (see comment above) events = (page?.items?.map(this.normalizeEvent) || []).filter(e => !!e && e.timestamp <= startDate); debug(`fetchEvents: finished fetching next page with ${events.length} events`); } } catch (error) { logging.error(error); throw error; } } async removeSuppression(type, email) { if (!this.isConfigured()) { return false; } const instance = this.getInstance(); const config = this.#getConfig(); try { await instance.suppressions.destroy( config.domain, type, email ); return true; } catch (err) { logging.error(err); return false; } } async removeBounce(email) { return this.removeSuppression('bounces', email); } async removeComplaint(email) { return this.removeSuppression('complaints', email); } async removeUnsubscribe(email) { return this.removeSuppression('unsubscribes', email); } normalizeEvent(event) { const providerId = event?.message?.headers['message-id']; if (!providerId && !(event['user-variables'] && event['user-variables']['email-id'])) { logging.error('Received invalid event from Mailgun'); logging.error(event); return null; } return { id: event.id, type: event.event, severity: event.severity, recipientEmail: event.recipient, emailId: event['user-variables'] && event['user-variables']['email-id'], providerId: providerId, timestamp: new Date(event.timestamp * 1000), error: event['delivery-status'] && (typeof (event['delivery-status'].message || event['delivery-status'].description) === 'string') ? { code: event['delivery-status'].code, message: (event['delivery-status'].message || event['delivery-status'].description).substring(0, 2000), enhancedCode: event['delivery-status']['enhanced-code']?.toString()?.substring(0, 50) ?? null } : null }; } #getConfig() { const bulkEmailConfig = this.#config.get('bulkEmail'); const bulkEmailSetting = { apiKey: this.#settings.get('mailgun_api_key'), domain: this.#settings.get('mailgun_domain'), baseUrl: this.#settings.get('mailgun_base_url') }; const hasMailgunConfig = !!(bulkEmailConfig?.mailgun); const hasMailgunSetting = !!(bulkEmailSetting && bulkEmailSetting.apiKey && bulkEmailSetting.baseUrl && bulkEmailSetting.domain); if (!hasMailgunConfig && !hasMailgunSetting) { return null; } const mailgunConfig = hasMailgunConfig ? bulkEmailConfig.mailgun : bulkEmailSetting; return mailgunConfig; } /** * Returns an instance of the Mailgun client based upon the config or settings values * * We don't cache the instance so we can always get a fresh one based upon changed settings * or config values over time * * Note: if the credentials are not configure, this method returns `null` and it is down to the * consumer to act upon this/log this out * * @returns {import('mailgun.js')|null} the Mailgun client instance */ getInstance() { const mailgunConfig = this.#getConfig(); if (!mailgunConfig) { return null; } const formData = require('form-data'); const Mailgun = require('mailgun.js'); const baseUrl = new URL(mailgunConfig.baseUrl); const mailgun = new Mailgun(formData); return mailgun.client({ username: 'api', key: mailgunConfig.apiKey, url: baseUrl.origin, timeout: 60000 }); } /** * Returns whether the Mailgun instance is configured via config/settings * * @returns {boolean} */ isConfigured() { const instance = this.getInstance(); return !!instance; } /** * Returns configured batch size * * @returns {number} */ getBatchSize() { return this.#config.get('bulkEmail')?.batchSize ?? this.DEFAULT_BATCH_SIZE; } };