2022-08-10 13:16:55 +03:00
const _ = require ( 'lodash' ) ;
const debug = require ( '@tryghost/debug' ) ;
const logging = require ( '@tryghost/logging' ) ;
2022-10-11 17:11:46 +03:00
const metrics = require ( '@tryghost/metrics' ) ;
2023-02-20 18:44:13 +03:00
const errors = require ( '@tryghost/errors' ) ;
2022-08-10 13:16:55 +03:00
module . exports = class MailgunClient {
# config ;
# settings ;
2022-08-18 23:14:54 +03:00
static BATCH _SIZE = 1000 ;
2022-08-10 13:16:55 +03:00
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 < Object > } replacements
*
* recipientData format :
* {
* 'test@example.com' : {
* name : 'Test User' ,
* unique _id : '12345abcde' ,
* unsubscribe _url : 'https://example.com/unsub/me'
* }
* }
* /
2022-08-15 13:52:29 +03:00
async send ( message , recipientData , replacements ) {
2022-08-10 13:16:55 +03:00
const mailgunInstance = this . getInstance ( ) ;
if ( ! mailgunInstance ) {
logging . warn ( ` Mailgun is not configured ` ) ;
2022-08-11 09:55:53 +03:00
return null ;
2022-08-10 13:16:55 +03:00
}
2022-08-18 23:28:10 +03:00
if ( Object . keys ( recipientData ) . length > MailgunClient . BATCH _SIZE ) {
2023-02-20 18:44:13 +03:00
throw new errors . IncorrectUsageError ( {
message : ` Mailgun only supports sending to ${ MailgunClient . BATCH _SIZE } recipients at a time `
} ) ;
2022-08-11 09:49:10 +03:00
}
2022-08-10 13:16:55 +03:00
let messageData = { } ;
2022-10-11 17:11:46 +03:00
let startTime ;
2022-08-10 13:16:55 +03:00
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 (
2022-10-18 11:32:50 +03:00
replacement . regexp ,
2022-08-10 13:16:55 +03:00
` %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 ,
2022-08-15 13:52:29 +03:00
'recipient-variables' : JSON . stringify ( recipientData )
2022-08-10 13:16:55 +03:00
} ;
// add a reference to the original email record for easier mapping of mailgun event -> email
if ( message . id ) {
messageData [ 'v:email-id' ] = message . id ;
}
2022-12-13 23:54:59 +03:00
const tags = [ 'bulk-email' , 'ghost-email' ] ;
2022-08-11 09:40:44 +03:00
if ( bulkEmailConfig ? . mailgun ? . tag ) {
2022-08-10 13:16:55 +03:00
tags . push ( bulkEmailConfig . mailgun . tag ) ;
}
messageData [ 'o:tag' ] = tags ;
2022-08-11 09:40:44 +03:00
if ( bulkEmailConfig ? . mailgun ? . testmode ) {
2022-08-10 13:16:55 +03:00
messageData [ 'o:testmode' ] = true ;
}
// enable tracking if turned on for this email
if ( message . track _opens ) {
messageData [ 'o:tracking-opens' ] = true ;
}
2022-08-15 13:52:29 +03:00
const mailgunConfig = this . # getConfig ( ) ;
2022-10-11 17:11:46 +03:00
startTime = Date . now ( ) ;
2022-08-15 13:52:29 +03:00
const response = await mailgunInstance . messages . create ( mailgunConfig . domain , messageData ) ;
2022-10-11 17:11:46 +03:00
metrics . metric ( 'mailgun-send-mail' , {
value : Date . now ( ) - startTime ,
statusCode : 200
} ) ;
2022-08-10 13:16:55 +03:00
2022-08-15 13:52:29 +03:00
return {
id : response . id
} ;
2022-08-10 13:16:55 +03:00
} catch ( error ) {
2022-10-11 17:11:46 +03:00
logging . error ( error ) ;
metrics . metric ( 'mailgun-send-mail' , {
value : Date . now ( ) - startTime ,
statusCode : error . status
} ) ;
2022-08-10 13:16:55 +03:00
return Promise . reject ( { error , messageData } ) ;
}
}
2023-04-19 10:58:23 +03:00
/ * *
* @ 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 ;
}
}
2023-02-20 18:44:13 +03:00
/ * *
* 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 < void > }
* /
2022-08-10 13:16:55 +03:00
async fetchEvents ( mailgunOptions , batchHandler , { maxEvents = Infinity } = { } ) {
const mailgunInstance = this . getInstance ( ) ;
if ( ! mailgunInstance ) {
logging . warn ( ` Mailgun is not configured ` ) ;
2023-02-20 18:44:13 +03:00
return ;
2022-08-10 13:16:55 +03:00
}
debug ( ` fetchEvents: starting fetching first events page ` ) ;
2022-08-15 13:52:29 +03:00
const mailgunConfig = this . # getConfig ( ) ;
2023-01-26 18:06:15 +03:00
const startDate = new Date ( ) ;
2023-04-19 10:58:23 +03:00
2022-10-11 17:11:46 +03:00
try {
2023-04-19 10:58:23 +03:00
let page = await this . getEventsFromMailgun ( mailgunInstance , mailgunConfig , mailgunOptions ) ;
2023-01-26 18:06:15 +03:00
// 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 ) ;
2022-10-11 17:11:46 +03:00
debug ( ` fetchEvents: finished fetching first page with ${ events . length } events ` ) ;
let eventCount = 0 ;
2023-02-20 18:44:13 +03:00
const beginTimestamp = mailgunOptions . begin ? Math . ceil ( mailgunOptions . begin * 1000 ) : undefined ; // ceil here if we have rounding errors
2022-10-11 17:11:46 +03:00
while ( events . length !== 0 ) {
2023-02-20 18:44:13 +03:00
await batchHandler ( events ) ;
2022-10-11 17:11:46 +03:00
eventCount += events . length ;
2023-02-20 18:44:13 +03:00
if ( eventCount >= maxEvents && ( ! beginTimestamp || ! events [ events . length - 1 ] . timestamp || ( events [ events . length - 1 ] . timestamp . getTime ( ) > beginTimestamp ) ) ) {
break ;
2022-10-11 17:11:46 +03:00
}
const nextPageId = page . pages . next . page ;
debug ( ` fetchEvents: starting fetching next page ${ nextPageId } ` ) ;
2023-04-19 10:58:23 +03:00
page = await this . getEventsFromMailgun ( mailgunInstance , mailgunConfig , {
2022-10-11 17:11:46 +03:00
page : nextPageId ,
... mailgunOptions
} ) ;
2023-04-19 10:58:23 +03:00
2023-01-26 18:06:15 +03:00
// 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 ) ;
2022-10-11 17:11:46 +03:00
debug ( ` fetchEvents: finished fetching next page with ${ events . length } events ` ) ;
2022-08-10 13:16:55 +03:00
}
2022-10-11 17:11:46 +03:00
} catch ( error ) {
logging . error ( error ) ;
throw error ;
2022-08-10 13:16:55 +03:00
}
}
2022-11-30 08:52:11 +03:00
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 ) ;
}
2022-08-10 13:16:55 +03:00
normalizeEvent ( event ) {
const providerId = event ? . message ? . headers [ 'message-id' ] ;
2022-12-14 13:17:45 +03:00
if ( ! providerId && ! ( event [ 'user-variables' ] && event [ 'user-variables' ] [ 'email-id' ] ) ) {
logging . error ( 'Received invalid event from Mailgun' ) ;
logging . error ( event ) ;
return null ;
}
2022-08-10 13:16:55 +03:00
return {
2022-12-01 12:00:53 +03:00
id : event . id ,
2022-08-10 13:16:55 +03:00
type : event . event ,
severity : event . severity ,
recipientEmail : event . recipient ,
emailId : event [ 'user-variables' ] && event [ 'user-variables' ] [ 'email-id' ] ,
providerId : providerId ,
2022-12-01 12:00:53 +03:00
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
2022-08-10 13:16:55 +03:00
} ;
}
2022-08-10 18:43:19 +03:00
# getConfig ( ) {
2022-08-10 13:16:55 +03:00
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' )
} ;
2022-08-11 09:40:44 +03:00
const hasMailgunConfig = ! ! ( bulkEmailConfig ? . mailgun ) ;
2022-08-10 13:16:55 +03:00
const hasMailgunSetting = ! ! ( bulkEmailSetting && bulkEmailSetting . apiKey && bulkEmailSetting . baseUrl && bulkEmailSetting . domain ) ;
if ( ! hasMailgunConfig && ! hasMailgunSetting ) {
return null ;
}
const mailgunConfig = hasMailgunConfig ? bulkEmailConfig . mailgun : bulkEmailSetting ;
2022-08-10 18:43:19 +03:00
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 / l o g t h i s o u t
*
2023-01-13 18:08:55 +03:00
* @ returns { import ( 'mailgun.js' ) | null } the Mailgun client instance
2022-08-10 18:43:19 +03:00
* /
getInstance ( ) {
const mailgunConfig = this . # getConfig ( ) ;
if ( ! mailgunConfig ) {
return null ;
}
2022-08-15 13:52:29 +03:00
const formData = require ( 'form-data' ) ;
const Mailgun = require ( 'mailgun.js' ) ;
2022-08-10 13:16:55 +03:00
const baseUrl = new URL ( mailgunConfig . baseUrl ) ;
2022-08-15 13:52:29 +03:00
const mailgun = new Mailgun ( formData ) ;
2022-08-10 13:16:55 +03:00
2022-08-15 13:52:29 +03:00
return mailgun . client ( {
username : 'api' ,
key : mailgunConfig . apiKey ,
2022-08-24 10:13:13 +03:00
url : baseUrl . origin ,
timeout : 60000
2022-08-10 13:16:55 +03:00
} ) ;
}
/ * *
* Returns whether the Mailgun instance is configured via config / settings
*
* @ returns { boolean }
* /
isConfigured ( ) {
const instance = this . getInstance ( ) ;
return ! ! instance ;
}
} ;