UNPKG

maildev

Version:

SMTP Server and Web Interface for reading and testing emails during development

633 lines (527 loc) 15.6 kB
'use strict' /** * MailDev - mailserver.js */ const SMTPServer = require('smtp-server').SMTPServer const MailParser = require('../vendor/mailparser-mit').MailParser const events = require('events') const fs = require('fs') const os = require('os') const path = require('path') const utils = require('./utils') const logger = require('./logger') const smtpHelpers = require('./helpers/smtp') const { calculateBcc } = require('./helpers/bcc') const outgoing = require('./outgoing') const createDOMPurify = require('dompurify') const { JSDOM } = require('jsdom') const { getAttachmentFilePath, transformAttachment } = require('./helpers/attachments') const store = [] const eventEmitter = new events.EventEmitter() const defaultPort = 1025 const defaultHost = '::' const defaultMailDir = path.join( os.tmpdir(), `maildev-${process.pid.toString()}` ) /** * Mail Server exports */ const mailServer = (module.exports = {}) mailServer.store = store /** * SMTP Server stream and helper functions */ // Save an email object on stream end function saveEmailToStore (id, isRead = false, envelope, parsedEmail) { const emlPath = path.join(mailServer.mailDir, id + '.eml') const stat = fs.statSync(emlPath) // serialize attachments without stream object const serializedAttachments = parsedEmail.attachments && parsedEmail.attachments.length ? parsedEmail.attachments.map((attachment) => { const { stream, ...remaining } = attachment return transformAttachment(remaining) }) : null const { attachments, ...parsedEmailRemaining } = parsedEmail const serialized = utils.clone(parsedEmailRemaining) serialized.id = id serialized.time = parsedEmail.date ? parsedEmail.date : new Date() serialized.read = isRead serialized.envelope = envelope serialized.source = path.join(mailServer.mailDir, id + '.eml') serialized.size = stat.size serialized.sizeHuman = utils.formatBytes(stat.size) serialized.attachments = serializedAttachments const onlyAddress = (xs) => (xs || []).map((x) => x.address) serialized.calculatedBcc = calculateBcc( onlyAddress(envelope.to), onlyAddress(parsedEmail.to), onlyAddress(parsedEmail.cc) ) store.push(serialized) logger.log('Saving email: %s, id: %s', parsedEmail.subject, id) if (outgoing.isAutoRelayEnabled()) { mailServer.relayMail(serialized, true, function (err) { if (err) logger.error('Error when relaying email', err) }) } eventEmitter.emit('new', serialized) } // Save an attachment function saveAttachment (id, rawAttachment) { if (!fs.existsSync(path.join(mailServer.mailDir, id))) { fs.mkdirSync(path.join(mailServer.mailDir, id)) } const attachment = transformAttachment(rawAttachment) const output = fs.createWriteStream(getAttachmentFilePath(mailServer.mailDir, id, attachment)) rawAttachment.stream.pipe(output) } /** * Handle smtp-server onData stream */ function handleDataStream (stream, session, callback) { const id = utils.makeId() const emlStream = fs.createWriteStream( path.join(mailServer.mailDir, id + '.eml') ) emlStream.on('open', function () { const parseStream = new MailParser({ streamAttachments: true }) parseStream.on( 'end', saveEmailToStore.bind(null, id, false, { from: session.envelope.mailFrom, to: session.envelope.rcptTo, host: session.hostNameAppearsAs, remoteAddress: session.remoteAddress }) ) parseStream.on('attachment', saveAttachment.bind(null, id)) stream.pipe(emlStream) stream.pipe(parseStream) stream.on('end', function () { emlStream.end() callback(null, 'Message queued as ' + id) }) }) } /** * Delete everything in the mail directory */ function clearMailDir () { fs.readdir(mailServer.mailDir, function (err, files) { if (err) throw err files.forEach(function (file) { fs.rm(path.join(mailServer.mailDir, file), { recursive: true }, function (err) { if (err) throw err }) }) }) } /** * Create mail directory */ function createMailDir () { if (!fs.existsSync(mailServer.mailDir)) { fs.mkdirSync(mailServer.mailDir) } logger.info('MailDev using directory %s', mailServer.mailDir) } /** * Create and configure the mailserver */ mailServer.create = function ( port, host, mailDir, user, password, hideExtensions, isSecure, certFilePath, keyFilePath ) { mailServer.mailDir = mailDir || defaultMailDir createMailDir() const hideExtensionOptions = getHideExtensionOptions(hideExtensions) const smtpServerConfig = Object.assign( { secure: isSecure, cert: certFilePath ? fs.readFileSync(certFilePath) : null, key: keyFilePath ? fs.readFileSync(keyFilePath) : null, onAuth: smtpHelpers.createOnAuthCallback(user, password), onData: handleDataStream, logger: false, hideSTARTTLS: true, disabledCommands: user && password ? (isSecure ? [] : ['STARTTLS']) : ['AUTH'] }, hideExtensionOptions ) const smtp = new SMTPServer(smtpServerConfig) smtp.on('error', mailServer.onSmtpError) mailServer.port = port || defaultPort mailServer.host = host || defaultHost // testability requires this to be exposed. // otherwise we cannot test whether error handling works mailServer.smtp = smtp } const HIDEABLE_EXTENSIONS = [ 'STARTTLS', // Keep it for backward compatibility, but is overriden by hardcoded `hideSTARTTLS` 'PIPELINING', '8BITMIME', 'SMTPUTF8' ] function getHideExtensionOptions (extensions) { if (!extensions) { return {} } return extensions.reduce(function (options, extension) { const ext = extension.toUpperCase() if (HIDEABLE_EXTENSIONS.indexOf(ext) > -1) { options[`hide${ext}`] = true } else { throw new Error(`Invalid hideable extension: ${ext}`) } return options }, {}) } /** * Start the mailServer */ mailServer.listen = function (callback) { if (typeof callback !== 'function') callback = null return new Promise(function (resolve, reject) { // Listen on the specified port mailServer.smtp.listen(mailServer.port, mailServer.host, function (err) { if (err) { if (callback) { callback(err) } else { reject(err) } } resolve(); if (callback) callback() const printHost = mailServer.host === '::' ? 'localhost' : mailServer.host; logger.info( 'MailDev SMTP Server running at %s:%s', printHost, mailServer.port ) }) }) } /** * Handle mailServer error */ mailServer.onSmtpError = function (err) { if (err.code === 'ECONNRESET' && err.syscall === 'read') { logger.warn( `Ignoring "${err.message}" error thrown by SMTP server. Likely the client connection closed prematurely. Full error details below.` ) logger.error(err) } else throw err } /** * Stop the mailserver */ mailServer.close = function (callback) { mailServer.emit('close') mailServer.smtp.close(callback) outgoing.close() } /** * Extend Event Emitter methods * events: * 'new' - emitted when new email has arrived */ mailServer.emit = eventEmitter.emit.bind(eventEmitter) mailServer.on = eventEmitter.on.bind(eventEmitter) mailServer.off = eventEmitter.off.bind(eventEmitter) mailServer.once = eventEmitter.once.bind(eventEmitter) mailServer.prependListener = eventEmitter.once.bind(eventEmitter) mailServer.prependOnceListener = eventEmitter.once.bind(eventEmitter) mailServer.removeListener = eventEmitter.removeListener.bind(eventEmitter) mailServer.removeAllListeners = eventEmitter.removeAllListeners.bind(eventEmitter) /** * Get an email by id */ mailServer.getEmail = function (id, done) { const email = store.filter(function (element) { return element.id === id })[0] if (email) { if (email.html) { // sanitize html const window = new JSDOM('').window const DOMPurify = createDOMPurify(window) email.html = DOMPurify.sanitize(email.html, { WHOLE_DOCUMENT: true, // preserve html,head,body elements SANITIZE_DOM: false, // ignore DOM cloberring to preserve form id/name attributes ADD_TAGS: ['link'], // allow link element to preserve external style sheets ADD_ATTR: ['target'] // Preserve explicit target attributes on links }) } done(null, email) } else { done(new Error('Email was not found')) } } /** * Returns a readable stream of the raw email */ mailServer.getRawEmail = function (id, done) { mailServer.getEmail(id, function (err, email) { if (err) return done(err) done(null, fs.createReadStream(path.join(mailServer.mailDir, id + '.eml'))) }) } /** * Returns the html of a given email */ mailServer.getEmailHTML = function (id, baseUrl, done) { if (!done && typeof baseUrl === 'function') { done = baseUrl baseUrl = null } if (baseUrl) { baseUrl = '//' + baseUrl } mailServer.getEmail(id, function (err, email) { if (err) return done(err) let html = email.html if (!email.attachments) { return done(null, html) } const embeddedAttachments = email.attachments.filter(function (attachment) { return attachment.contentId }) const getFileUrl = function (id, baseUrl, filename) { return ( (baseUrl || '') + '/email/' + id + '/attachment/' + encodeURIComponent(filename) ) } if (embeddedAttachments.length) { embeddedAttachments.forEach(function (attachment) { const regex = new RegExp( "src=(\"|')cid:" + attachment.contentId + "(\"|')", 'g' ) const replacement = 'src="' + getFileUrl(id, baseUrl, attachment.generatedFileName) + '"' html = html.replace(regex, replacement) }) } done(null, html) }) } /** * Read single email */ // mailServer.readEmail = function (id, done) { // var email = store.filter(function (element) { // return element.id === id // })[0] // if (email) { // email.read = true // done(null, email) // } else { // done(new Error('Email was not found')) // } // } /** * Read all emails */ mailServer.readAllEmail = function (done) { const allUnread = store.filter(function (element) { return !element.read }) for (const email of allUnread) { email.read = true } done(null, allUnread.length) } /** * Get all email */ mailServer.getAllEmail = function (done) { done(null, store) } /** * Delete an email by id */ mailServer.deleteEmail = function (id, done) { let email = null let emailIndex = null store.forEach(function (element, index) { if (element.id === id) { email = element emailIndex = index } }) if (emailIndex === null) { return done(new Error('Email not found')) } // delete raw email fs.unlink(path.join(mailServer.mailDir, id + '.eml'), function (err) { if (err) { logger.error(err) } else { eventEmitter.emit('delete', { id, index: emailIndex }) } }) if (email.attachments) { fs.rm(path.join(mailServer.mailDir, id), { recursive: true }, function (err) { if (err) throw err }) } logger.warn('Deleting email - %s', email.subject) store.splice(emailIndex, 1) done(null, true) } /** * Delete all emails in the store */ mailServer.deleteAllEmail = function (done) { logger.warn('Deleting all email') clearMailDir() store.length = 0 eventEmitter.emit('delete', { id: 'all' }) done(null, true) } /** * Returns the content type and a readable stream of the file */ mailServer.getEmailAttachment = function (id, filename, done) { mailServer.getEmail(id, function (err, email) { if (err) return done(err) if (!email.attachments || !email.attachments.length) { return done(new Error('Email has no attachments')) } const match = email.attachments.filter(function (attachment) { return attachment.generatedFileName === filename })[0] if (!match) { return done(new Error('Attachment not found')) } done( null, match.contentType, fs.createReadStream(getAttachmentFilePath(mailServer.mailDir, id, match)) ) }) } /** * Setup outgoing */ mailServer.setupOutgoing = function (host, port, user, pass, secure) { outgoing.setup(host, port, user, pass, secure) } mailServer.isOutgoingEnabled = function () { return outgoing.isEnabled() } mailServer.getOutgoingHost = function () { return outgoing.getOutgoingHost() } /** * Set Auto Relay Mode, automatic send email to recipient */ mailServer.setAutoRelayMode = function (enabled, rules, emailAddress) { outgoing.setAutoRelayMode(enabled, rules, emailAddress) } /** * Relay a given email, accepts a mail id or a mail object */ mailServer.relayMail = function (idOrMailObject, isAutoRelay, done) { if (!outgoing.isEnabled()) { return done(new Error('Outgoing mail not configured')) } // isAutoRelay is an option argument if (typeof isAutoRelay === 'function') { done = isAutoRelay isAutoRelay = false } // If we receive a email id, get the email object if (typeof idOrMailObject === 'string') { mailServer.getEmail(idOrMailObject, function (err, email) { if (err) return done(err) mailServer.relayMail(email, done) }) return } const mail = idOrMailObject mailServer.getRawEmail(mail.id, function (err, rawEmailStream) { if (err) { logger.error('Mail Stream Error: ', err) return done(err) } outgoing.relayMail(mail, rawEmailStream, isAutoRelay, done) }) } /** * Download a given email */ mailServer.getEmailEml = function (id, done) { mailServer.getEmail(id, function (err, email) { if (err) return done(err) const filename = email.id + '.eml' done( null, 'message/rfc822', filename, fs.createReadStream(path.join(mailServer.mailDir, id + '.eml')) ) }) } mailServer.loadMailsFromDirectory = function () { const persistencePath = fs.realpathSync(mailServer.mailDir) fs.readdir(persistencePath, function (err, files) { if (err) { logger.error('Error during reading of the mailDir %s', persistencePath) } else { store.length = 0 files.forEach(function (file) { const filePath = persistencePath + '/' + file if (path.parse(file).ext === '.eml') { fs.readFile(filePath, 'utf8', function (err, data) { if (err) { logger.error('Error during reading of the file %s', filePath) } else { const idMail = path.parse(file).name const parseStream = new MailParser({ streamAttachments: true }) logger.log('Restore mail %s', idMail) const envelope = { from: '', to: '', host: 'undefined', remoteAddress: 'undefined' } parseStream.on('from', function (from) { envelope.from = from }) parseStream.on('to', function (to) { envelope.to = to }) parseStream.on( 'end', saveEmailToStore.bind(null, idMail, true, envelope) ) parseStream.on('attachment', saveAttachment.bind(null, idMail)) parseStream.write(data) parseStream.end() } }) } }) } }) }