diff --git a/ghost/core/core/server/adapters/email/EmailProviderBase.js b/ghost/core/core/server/adapters/email/EmailProviderBase.js new file mode 100644 index 00000000000..3ddf953866e --- /dev/null +++ b/ghost/core/core/server/adapters/email/EmailProviderBase.js @@ -0,0 +1,78 @@ +/** + * Base class for email provider adapters + * + * All email providers must implement the required methods defined below. + * This ensures consistent interface across different email providers (Postmark, SendGrid, AWS SES, etc.) + * + * This base class handles both email sending and analytics fetching in a unified interface. + */ +class EmailProviderBase { + /** + * Required methods that all email providers must implement + */ + static requiredFns = ['send', 'getMaximumRecipients', 'getTargetDeliveryWindow', 'fetchLatest']; + + constructor(config) { + this.config = config; + } + + /** + * Send an email + * + * @param {Object} data - Email data + * @param {string} data.subject - Email subject + * @param {string} data.html - HTML content + * @param {string} data.plaintext - Plain text content + * @param {string} data.from - Sender email address + * @param {string} data.emailId - Email ID for tracking + * @param {string} [data.replyTo] - Reply-to address + * @param {string} [data.domainOverride] - Override domain + * @param {Array} data.recipients - Array of recipients + * @param {Array} data.replacementDefinitions - Replacement variable definitions + * + * @param {Object} options - Sending options + * @param {boolean} options.clickTrackingEnabled - Enable click tracking + * @param {boolean} options.openTrackingEnabled - Enable open tracking + * @param {Date} [options.deliveryTime] - Scheduled delivery time + * + * @returns {Promise<{id: string}>} Provider message ID + */ + async send(data, options) { + throw new Error('EmailProviderBase.send must be implemented by the email adapter'); + } + + /** + * Get maximum number of recipients per batch + * + * @returns {number} Maximum recipients + */ + getMaximumRecipients() { + throw new Error('EmailProviderBase.getMaximumRecipients must be implemented by the email adapter'); + } + + /** + * Get target delivery window in milliseconds + * + * @returns {number} Delivery window in milliseconds + */ + getTargetDeliveryWindow() { + throw new Error('EmailProviderBase.getTargetDeliveryWindow must be implemented by the email adapter'); + } + + /** + * Fetch latest email events for analytics + * + * @param {Function} batchHandler - Handler for processing event batches + * @param {Object} [options] - Fetch options + * @param {number} [options.maxEvents] - Maximum events to fetch (not strict) + * @param {Date} [options.begin] - Start date for events + * @param {Date} [options.end] - End date for events + * @param {String[]} [options.events] - Event types to fetch + * @returns {Promise} + */ + async fetchLatest(batchHandler, options) { + throw new Error('EmailProviderBase.fetchLatest must be implemented by the email adapter'); + } +} + +module.exports = EmailProviderBase; diff --git a/ghost/core/core/server/adapters/email/Mailgun.js b/ghost/core/core/server/adapters/email/Mailgun.js new file mode 100644 index 00000000000..24b277e8b76 --- /dev/null +++ b/ghost/core/core/server/adapters/email/Mailgun.js @@ -0,0 +1,80 @@ +const EmailProviderBase = require('./EmailProviderBase'); +const MailgunEmailProvider = require('../../services/email-service/mailgun-email-provider'); +const EmailAnalyticsProviderMailgun = require('../../services/email-analytics/email-analytics-provider-mailgun'); +const MailgunClient = require('../../services/lib/mailgun-client'); + +/** + * Mailgun Email Adapter + * + * Thin wrapper around existing MailgunEmailProvider and EmailAnalyticsProviderMailgun + * to conform to the unified adapter pattern. + * + * @extends EmailProviderBase + */ +class Mailgun extends EmailProviderBase { + #emailProvider; + #analyticsProvider; + + /** + * @param {Object} config - Adapter configuration + * @param {Object} config.configService - Ghost config service + * @param {Object} config.settingsCache - Ghost settings cache + * @param {Object} config.labs - Ghost labs service + * @param {Function} [config.errorHandler] - Custom error handler + */ + constructor(config) { + super(config); + + const {configService, settingsCache, labs, errorHandler} = config; + + // Initialize Mailgun client (shared between email and analytics) + const mailgunClient = new MailgunClient({ + config: configService, + settings: settingsCache, + labs + }); + + // Initialize the existing email provider + this.#emailProvider = new MailgunEmailProvider({ + mailgunClient, + errorHandler + }); + + // Initialize the existing analytics provider + this.#analyticsProvider = new EmailAnalyticsProviderMailgun({ + config: configService, + settings: settingsCache, + labs + }); + } + + /** + * Send an email (delegates to existing MailgunEmailProvider) + */ + async send(data, options) { + return await this.#emailProvider.send(data, options); + } + + /** + * Get maximum recipients per batch (delegates to existing MailgunEmailProvider) + */ + getMaximumRecipients() { + return this.#emailProvider.getMaximumRecipients(); + } + + /** + * Get target delivery window (delegates to existing MailgunEmailProvider) + */ + getTargetDeliveryWindow() { + return this.#emailProvider.getTargetDeliveryWindow(); + } + + /** + * Fetch latest email events for analytics (delegates to existing EmailAnalyticsProviderMailgun) + */ + async fetchLatest(batchHandler, options) { + return await this.#analyticsProvider.fetchLatest(batchHandler, options); + } +} + +module.exports = Mailgun; diff --git a/ghost/core/core/server/adapters/email/index.js b/ghost/core/core/server/adapters/email/index.js new file mode 100644 index 00000000000..a2b63f56d68 --- /dev/null +++ b/ghost/core/core/server/adapters/email/index.js @@ -0,0 +1,21 @@ +const adapterManager = require('../../services/adapter-manager'); + +/** + * Get an email adapter instance + * + * @param {string} [feature] - Optional feature name for feature-specific adapter (e.g., 'transactional', 'bulk') + * @returns {Object} Email adapter instance + */ +function getEmailAdapter(feature) { + let adapterName = 'email'; + + if (feature) { + adapterName += `:${feature}`; + } + + return adapterManager.getAdapter(adapterName); +} + +module.exports = { + getEmailAdapter +}; diff --git a/ghost/core/core/server/services/adapter-manager/index.js b/ghost/core/core/server/services/adapter-manager/index.js index 736643ce74a..f95467a1c2d 100644 --- a/ghost/core/core/server/services/adapter-manager/index.js +++ b/ghost/core/core/server/services/adapter-manager/index.js @@ -17,6 +17,7 @@ adapterManager.registerAdapter('scheduling', require('../../adapters/scheduling/ adapterManager.registerAdapter('sso', require('../../adapters/sso/SSOBase')); adapterManager.registerAdapter('cache', require('@tryghost/adapter-base-cache')); adapterManager.registerAdapter('redirects', require('../../adapters/redirects/RedirectsStoreBase')); +adapterManager.registerAdapter('email', require('../../adapters/email/EmailProviderBase')); module.exports = { /** diff --git a/ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js b/ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js index ecad4536815..fcdb8ea9f8f 100644 --- a/ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js +++ b/ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js @@ -13,7 +13,6 @@ class EmailAnalyticsServiceWrapper { const EmailAnalyticsService = require('./email-analytics-service'); const EmailEventStorage = require('../email-service/email-event-storage'); const EmailEventProcessor = require('../email-service/email-event-processor'); - const MailgunProvider = require('./email-analytics-provider-mailgun'); const {EmailRecipientFailure, EmailSpamComplaintEvent, Email} = require('../../models'); const StartEmailAnalyticsJobEvent = require('./events/start-email-analytics-job-event'); const domainEvents = require('@tryghost/domain-events'); @@ -47,13 +46,44 @@ class EmailAnalyticsServiceWrapper { prometheusClient }); + // Use unified email adapter (handles both sending and analytics) + const bulkEmailConfig = config.get('bulkEmail'); + const emailProvider = bulkEmailConfig?.provider || 'mailgun'; + + logging.info(`[EmailAnalytics] Initializing ${emailProvider} analytics via unified adapter`); + + const emailAdapter = require('../../adapters/email'); + const providers = []; + + try { + // Get unified email adapter instance (same one used for email sending) + const adapterInstance = emailAdapter.getEmailAdapter(); + + // Inject dependencies needed by the adapter + const AdapterClass = adapterInstance.constructor; + const adapterConfig = { + configService: config, + settingsCache: settings + }; + + // Add labs for Mailgun + if (emailProvider === 'mailgun') { + adapterConfig.labs = labs; + } + + // Create a new instance for analytics (the email service has its own instance) + providers.push(new AdapterClass(adapterConfig)); + } catch (error) { + logging.error(`[EmailAnalytics] Failed to load ${emailProvider} adapter: ${error.message}`); + logging.error(error.stack); + throw error; + } + this.service = new EmailAnalyticsService({ config, settings, eventProcessor, - providers: [ - new MailgunProvider({config, settings, labs}) - ], + providers, queries, domainEvents, prometheusClient diff --git a/ghost/core/core/server/services/email-service/email-service-wrapper.js b/ghost/core/core/server/services/email-service/email-service-wrapper.js index 6c3730f4e4e..5807e7adbaf 100644 --- a/ghost/core/core/server/services/email-service/email-service-wrapper.js +++ b/ghost/core/core/server/services/email-service/email-service-wrapper.js @@ -21,11 +21,9 @@ class EmailServiceWrapper { const SendingService = require('./sending-service'); const BatchSendingService = require('./batch-sending-service'); const EmailSegmenter = require('./email-segmenter'); - const MailgunEmailProvider = require('./mailgun-email-provider'); const {DomainWarmingService} = require('./domain-warming-service'); const {Post, Newsletter, Email, EmailBatch, EmailRecipient, Member} = require('../../models'); - const MailgunClient = require('../lib/mailgun-client'); const configService = require('../../../shared/config'); const settingsCache = require('../../../shared/settings-cache'); const settingsHelpers = require('../settings-helpers'); @@ -49,16 +47,46 @@ class EmailServiceWrapper { const emailAnalyticsJobs = require('../email-analytics/jobs'); const {cachedImageSizeFromUrl} = require('../../lib/image'); - // capture errors from mailgun client and log them in sentry + // Determine which email provider to use based on configuration + const bulkEmailConfig = configService.get('bulkEmail'); + const emailProvider = bulkEmailConfig?.provider || 'mailgun'; + + // capture errors from email provider and log them in sentry const errorHandler = (error) => { - logging.info(`Capturing error for mailgun email provider service`); + logging.info(`Capturing error for ${emailProvider} email provider service`); sentry.captureException(error); }; - // Mailgun client instance for email provider - const mailgunClient = new MailgunClient({ - config: configService, settings: settingsCache, labs - }); + let emailProviderInstance; + + // Use adapter pattern for all email providers + logging.info(`Initializing ${emailProvider} email provider via adapter`); + + const emailAdapter = require('../../adapters/email'); + + // Get adapter instance with injected dependencies + emailProviderInstance = emailAdapter.getEmailAdapter(); + + // Inject dependencies needed by the adapter + const AdapterClass = emailProviderInstance.constructor; + const adapterConfig = { + configService, + settingsCache, + errorHandler + }; + + // Add labs for Mailgun + if (emailProvider === 'mailgun') { + adapterConfig.labs = labs; + } + + // Merge with provider-specific config + if (bulkEmailConfig[emailProvider]) { + Object.assign(adapterConfig, bulkEmailConfig[emailProvider]); + } + + emailProviderInstance = new AdapterClass(adapterConfig); + const i18nLanguage = settingsCache.get('locale') || 'en'; const i18n = i18nLib(i18nLanguage, 'ghost'); @@ -67,11 +95,6 @@ class EmailServiceWrapper { i18n.changeLanguage(model.get('value')); }); - const mailgunEmailProvider = new MailgunEmailProvider({ - mailgunClient, - errorHandler - }); - const emailRenderer = new EmailRenderer({ settingsCache, settingsHelpers, @@ -96,7 +119,7 @@ class EmailServiceWrapper { }); const sendingService = new SendingService({ - emailProvider: mailgunEmailProvider, + emailProvider: emailProviderInstance, emailRenderer, emailAddressService: emailAddressService.service }); diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 411258a3713..e4d478f5c56 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -39,6 +39,10 @@ "active": "FileStore", "FileStore": {}, "S3RedirectsStore": {} + }, + "email": { + "active": "Mailgun", + "Mailgun": {} } }, "storage": { diff --git a/ghost/core/test/unit/server/adapters/email/EmailProviderBase.test.js b/ghost/core/test/unit/server/adapters/email/EmailProviderBase.test.js new file mode 100644 index 00000000000..faecbacb590 --- /dev/null +++ b/ghost/core/test/unit/server/adapters/email/EmailProviderBase.test.js @@ -0,0 +1,62 @@ +const EmailProviderBase = require('../../../../../core/server/adapters/email/EmailProviderBase'); +const assert = require('node:assert/strict'); + +describe('EmailProviderBase', function () { + it('has required functions defined', function () { + assert.deepEqual( + EmailProviderBase.requiredFns, + ['send', 'getMaximumRecipients', 'getTargetDeliveryWindow', 'fetchLatest'] + ); + }); + + it('throws error when send is not implemented', async function () { + const provider = new EmailProviderBase({}); + + await assert.rejects( + async () => await provider.send({}, {}), + { + message: 'EmailProviderBase.send must be implemented by the email adapter' + } + ); + }); + + it('throws error when getMaximumRecipients is not implemented', function () { + const provider = new EmailProviderBase({}); + + assert.throws( + () => provider.getMaximumRecipients(), + { + message: 'EmailProviderBase.getMaximumRecipients must be implemented by the email adapter' + } + ); + }); + + it('throws error when getTargetDeliveryWindow is not implemented', function () { + const provider = new EmailProviderBase({}); + + assert.throws( + () => provider.getTargetDeliveryWindow(), + { + message: 'EmailProviderBase.getTargetDeliveryWindow must be implemented by the email adapter' + } + ); + }); + + it('throws error when fetchLatest is not implemented', async function () { + const provider = new EmailProviderBase({}); + + await assert.rejects( + async () => await provider.fetchLatest(() => {}, {}), + { + message: 'EmailProviderBase.fetchLatest must be implemented by the email adapter' + } + ); + }); + + it('stores config in constructor', function () { + const config = {configService: {}, settingsCache: {}}; + const provider = new EmailProviderBase(config); + + assert.equal(provider.config, config); + }); +}); diff --git a/ghost/core/test/unit/server/adapters/email/Mailgun.test.js b/ghost/core/test/unit/server/adapters/email/Mailgun.test.js new file mode 100644 index 00000000000..bd0894c6dd2 --- /dev/null +++ b/ghost/core/test/unit/server/adapters/email/Mailgun.test.js @@ -0,0 +1,141 @@ +const Mailgun = require('../../../../../core/server/adapters/email/Mailgun'); +const EmailProviderBase = require('../../../../../core/server/adapters/email/EmailProviderBase'); +const sinon = require('sinon'); +const assert = require('node:assert/strict'); + +describe('Mailgun Adapter', function () { + let mailgunClient; + let emailProvider; + let analyticsProvider; + + beforeEach(function () { + // Mock MailgunClient + mailgunClient = { + getBatchSize: sinon.stub().returns(1000), + getTargetDeliveryWindow: sinon.stub().returns(3600000), + fetchEvents: sinon.stub().resolves() + }; + + // Mock MailgunEmailProvider + emailProvider = { + send: sinon.stub().resolves({id: 'msg-123'}), + getMaximumRecipients: sinon.stub().returns(1000), + getTargetDeliveryWindow: sinon.stub().returns(3600000) + }; + + // Mock EmailAnalyticsProviderMailgun + analyticsProvider = { + fetchLatest: sinon.stub().resolves() + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('extends EmailProviderBase', function () { + const adapter = new Mailgun({ + configService: {get: sinon.stub()}, + settingsCache: {}, + labs: {} + }); + + assert.ok(adapter instanceof EmailProviderBase); + }); + + it('delegates send to MailgunEmailProvider', async function () { + const adapter = new Mailgun({ + configService: {get: sinon.stub()}, + settingsCache: {}, + labs: {}, + errorHandler: () => {} + }); + + // Replace the internal provider with our mock + adapter._Mailgun__emailProvider = emailProvider; + + const data = { + subject: 'Test', + html: 'Test', + recipients: [{email: 'test@example.com', replacements: []}], + replacementDefinitions: [] + }; + const options = {openTrackingEnabled: true}; + + const result = await adapter.send(data, options); + + assert.ok(emailProvider.send.calledOnce); + assert.ok(emailProvider.send.calledWith(data, options)); + assert.deepEqual(result, {id: 'msg-123'}); + }); + + it('delegates getMaximumRecipients to MailgunEmailProvider', function () { + const adapter = new Mailgun({ + configService: {get: sinon.stub()}, + settingsCache: {}, + labs: {} + }); + + adapter._Mailgun__emailProvider = emailProvider; + + const result = adapter.getMaximumRecipients(); + + assert.ok(emailProvider.getMaximumRecipients.calledOnce); + assert.equal(result, 1000); + }); + + it('delegates getTargetDeliveryWindow to MailgunEmailProvider', function () { + const adapter = new Mailgun({ + configService: {get: sinon.stub()}, + settingsCache: {}, + labs: {} + }); + + adapter._Mailgun__emailProvider = emailProvider; + + const result = adapter.getTargetDeliveryWindow(); + + assert.ok(emailProvider.getTargetDeliveryWindow.calledOnce); + assert.equal(result, 3600000); + }); + + it('delegates fetchLatest to EmailAnalyticsProviderMailgun', async function () { + const adapter = new Mailgun({ + configService: {get: sinon.stub()}, + settingsCache: {}, + labs: {} + }); + + adapter._Mailgun__analyticsProvider = analyticsProvider; + + const batchHandler = sinon.stub(); + const options = { + maxEvents: 100, + begin: new Date('2024-01-01'), + end: new Date('2024-01-31') + }; + + await adapter.fetchLatest(batchHandler, options); + + assert.ok(analyticsProvider.fetchLatest.calledOnce); + assert.ok(analyticsProvider.fetchLatest.calledWith(batchHandler, options)); + }); + + it('creates providers with correct dependencies', function () { + const configService = {get: sinon.stub()}; + const settingsCache = {get: sinon.stub()}; + const labs = {isSet: sinon.stub()}; + const errorHandler = sinon.stub(); + + const adapter = new Mailgun({ + configService, + settingsCache, + labs, + errorHandler + }); + + // Verify adapter was created successfully + assert.ok(adapter); + assert.ok(adapter instanceof EmailProviderBase); + }); +});