UNPKG

@timshel_npm/maildev

Version:

SMTP Server with async API and Web Interface for viewing and testing emails during development

487 lines (486 loc) 21.2 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __await = (this && this.__await) || function (v) { return this instanceof __await ? (this.v = v, this) : new __await(v); } var __asyncGenerator = (this && this.__asyncGenerator) || function (thisArg, _arguments, generator) { if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); var g = generator.apply(thisArg, _arguments || []), i, q = []; return i = Object.create((typeof AsyncIterator === "function" ? AsyncIterator : Object).prototype), verb("next"), verb("throw"), verb("return", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i; function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; } function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } } function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } } function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); } function fulfill(value) { resume("next", value); } function reject(value) { resume("throw", value); } function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); } }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MailServer = void 0; const bcc_1 = require("./helpers/bcc"); const smtp_1 = require("./helpers/smtp"); const mailbuffer_1 = require("./mailbuffer"); const mailparser_1 = require("./mailparser"); const outgoing_1 = require("./outgoing"); const smtp_server_1 = require("smtp-server"); const fs_1 = require("fs"); 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 createDOMPurify = require("dompurify"); const { JSDOM } = require("jsdom"); const defaultPort = 1025; const defaultHost = "0.0.0.0"; const reservedEventName = ["close", "delete"]; class MailServer { next(subject) { if (reservedEventName.includes(subject)) { throw new Error(`Invalid subject ${subject}; ${reservedEventName} are reserved for internal usage`); } return new Promise((resolve) => { this.once(subject, resolve); }); } /** * Use an internal array to store received email even if not consummed * Use `.return()` to close it **/ iterator(subject) { if (reservedEventName.includes(subject)) { throw new Error(`Invalid subject ${subject}; ${reservedEventName} are reserved for internal usage`); } let closed = false; const next = []; const self = this; const closing = () => { closed = true; }; let nextCallback; function buildNext() { return new Promise((resolve) => { nextCallback = (mail) => { next.push(buildNext()); resolve(mail); }; self.once(subject, nextCallback); }); } self.once("close", closing); next.push(buildNext()); // We use an internal generator otherwise the setup phase was not always run function inner(subject) { return __asyncGenerator(this, arguments, function* inner_1() { try { do { const email = (yield __await(next.shift())); yield yield __await(email); } while (!closed); } finally { self.removeListener("close", closing); if (nextCallback) { self.removeListener(subject, nextCallback); } } }); } return inner(subject); } /** * Return a struct which store received emails. * Then allow to obtain a `Promise<Mail>` dependant on a predicate `(Mail) => boolean`. * Allow to wait for `Mail` independant of their order of arrival. */ buffer(subject, defaultTimeout = 10000) { return new mailbuffer_1.MailBuffer(this, subject, defaultTimeout); } constructor(options, mailEventSubjectMapper = (m) => { var _a; return (_a = m.to[0]) === null || _a === void 0 ? void 0 : _a.address; }) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; this.store = []; this.eventEmitter = new events.EventEmitter(); /** * Extend Event Emitter methods * events: * 'new' - emitted when new email has arrived */ this.emit = this.eventEmitter.emit.bind(this.eventEmitter); this.on = this.eventEmitter.on.bind(this.eventEmitter); this.off = this.eventEmitter.off.bind(this.eventEmitter); this.once = this.eventEmitter.once.bind(this.eventEmitter); this.prependListener = this.eventEmitter.once.bind(this.eventEmitter); this.prependOnceListener = this.eventEmitter.once.bind(this.eventEmitter); this.removeListener = this.eventEmitter.removeListener.bind(this.eventEmitter); this.removeAllListeners = this.eventEmitter.removeAllListeners.bind(this.eventEmitter); const defaultMailDir = path.join(os.tmpdir(), `maildev-${process.pid.toString()}`); const secure = (_a = options === null || options === void 0 ? void 0 : options.isSecure) !== null && _a !== void 0 ? _a : false; const smtpServerConfig = { secure, cert: (options === null || options === void 0 ? void 0 : options.ssl) ? fs.readFileSync((_b = options === null || options === void 0 ? void 0 : options.ssl) === null || _b === void 0 ? void 0 : _b.certPath) : null, key: (options === null || options === void 0 ? void 0 : options.ssl) ? fs.readFileSync((_c = options === null || options === void 0 ? void 0 : options.ssl) === null || _c === void 0 ? void 0 : _c.keyPath) : null, onAuth: (0, smtp_1.createOnAuthCallback)((_d = options === null || options === void 0 ? void 0 : options.auth) === null || _d === void 0 ? void 0 : _d.user, (_e = options === null || options === void 0 ? void 0 : options.auth) === null || _e === void 0 ? void 0 : _e.pass), onData: (stream, session, callback) => handleDataStream(this, stream, session, callback), logger: false, hideSTARTTLS: true, hidePIPELINING: (_f = options === null || options === void 0 ? void 0 : options.hidePIPELINING) !== null && _f !== void 0 ? _f : false, hide8BITMIME: (_g = options === null || options === void 0 ? void 0 : options.hide8BITMIME) !== null && _g !== void 0 ? _g : false, hideSMTPUTF8: (_h = options === null || options === void 0 ? void 0 : options.hideSMTPUTF8) !== null && _h !== void 0 ? _h : false, disabledCommands: (options === null || options === void 0 ? void 0 : options.auth) ? (secure ? [] : ["STARTTLS"]) : ["AUTH"], }; this.port = (_j = options === null || options === void 0 ? void 0 : options.port) !== null && _j !== void 0 ? _j : defaultPort; this.host = (_k = options === null || options === void 0 ? void 0 : options.host) !== null && _k !== void 0 ? _k : defaultHost; this.mailDir = (_l = options === null || options === void 0 ? void 0 : options.mailDir) !== null && _l !== void 0 ? _l : defaultMailDir; this.mailEventSubjectMapper = mailEventSubjectMapper; this.smtp = new smtp_server_1.SMTPServer(smtpServerConfig); this.smtp.on("error", onSmtpError); createMailDir(this.mailDir); if (options === null || options === void 0 ? void 0 : options.outgoing) { this.outgoing = new outgoing_1.Outgoing(options === null || options === void 0 ? void 0 : options.outgoing); } if (options === null || options === void 0 ? void 0 : options.mailDir) { this.loadMailsFromDirectory(); } } /** * Start the mailServer */ listen() { const self = this; return new Promise((resolve, reject) => { self.smtp.listen(self.port, self.host, (err) => { if (err) { reject(err); } logger.info("MailDev SMTP Server running at %s:%s", self.host, self.port); resolve(); }); }); } /** * Stop the mailserver */ close() { var _a; this.emit("close"); (_a = this.outgoing) === null || _a === void 0 ? void 0 : _a.close(); return new Promise((resolve) => { this.smtp.close(resolve); }); } isOutgoingEnabled() { return this.outgoing !== undefined; } getOutgoingHost() { var _a; return (_a = this.outgoing) === null || _a === void 0 ? void 0 : _a.getOutgoingHost(); } /** * Set Auto Relay Mode, automatic send email to recipient */ setAutoRelayMode(enabled, emailAddress, rules) { if (this.outgoing) { this.outgoing.setAutoRelayMode(enabled, emailAddress, rules); } else { throw new Error("Outgoing not configured"); } } relayMail(mail, isAutoRelay = true) { const self = this; const outgoing = this.outgoing; return outgoing ? new Promise((resolve, reject) => { self .getRawEmail(mail.id) .then((rawEmailStream) => { outgoing.relayMail(mail, rawEmailStream, isAutoRelay, (err) => { if (err) { reject(err); } else { resolve(); } }); }) .catch((err) => { logger.error("Mail Stream Error: ", err); reject(err); }); }) : Promise.reject(new Error("Outgoing not configured")); } /** * Get an email by id */ getEmail(id) { return __awaiter(this, void 0, void 0, function* () { const envelope = this.store.find(function (elt) { return elt.id === id; }); if (envelope) { return getDiskEmail(this.mailDir, envelope.id).then((mail) => { if (mail.html) { // sanitize html const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); mail.html = DOMPurify.sanitize(mail.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 }); } return mail; }); } else { throw new Error(`No email with id: ${id}`); } }); } /** * Returns a readable stream of the raw email */ getRawEmail(id) { return __awaiter(this, void 0, void 0, function* () { const emlPath = path.join(this.mailDir, id + ".eml"); if (fs.existsSync(emlPath)) { return fs.createReadStream(emlPath); } else { throw new Error(`No email with id: ${id}`); } }); } /** * Returns the html of a given email */ getEmailHTML(id_1) { return __awaiter(this, arguments, void 0, function* (id, baseUrl = "") { baseUrl = baseUrl ? "//" + baseUrl : ""; const mail = yield this.getEmail(id); if (typeof mail.html === "string") { var html = mail.html; const getFileUrl = function (filename) { return baseUrl + "/email/" + id + "/attachment/" + encodeURIComponent(filename); }; for (const attachment of mail.attachments) { if (attachment.contentId) { const regex = new RegExp("src=(\"|')cid:" + attachment.contentId + "(\"|')", "g"); const replacement = 'src="' + getFileUrl(attachment.generatedFileName) + '"'; html = html.replace(regex, replacement); } } return html; } else { throw new Error(`No html in email ${id}`); } }); } /** * Set all emails to read */ readAllEmail() { return this.store.reduce(function (count, elt) { if (!elt.isRead) { count++; } return count; }, 0); } getAllEnvelope() { return this.store.slice(); } getAllEmail() { const emails = this.store.map((elt) => { return this.getEmail(elt.id); }); return Promise.all(emails); } deleteEmail(id) { return __awaiter(this, void 0, void 0, function* () { const self = this; const emailIndex = this.store.findIndex((elt) => elt.id === id); if (emailIndex < 0) { throw new Error(`Email ${id} not found`); } const mail = this.store[emailIndex]; logger.warn("Deleting email - %s", mail.id); this.store.splice(emailIndex, 1); return Promise.all([ fs_1.promises.unlink(path.join(this.mailDir, id + ".eml")).catch((err) => { throw new Error(`Error when deleteing ${id}`); }), fs_1.promises.rm(path.join(this.mailDir, id), { recursive: true, force: true }).catch((err) => { throw new Error(`Error when deleteing ${id} attachments: ${err}`); }), ]).then(() => { self.eventEmitter.emit("delete", { id, index: emailIndex }); return true; }); }); } deleteAllEmail() { return __awaiter(this, void 0, void 0, function* () { logger.warn("Deleting all email"); this.clearMailDir(); this.store.length = 0; this.eventEmitter.emit("delete", { id: "all" }); return true; }); } /** * Delete everything in the mail directory */ clearMailDir() { return __awaiter(this, void 0, void 0, function* () { const self = this; const files = yield fs_1.promises.readdir(this.mailDir); const rms = files.map(function (file) { return fs_1.promises.rm(path.join(self.mailDir, file), { recursive: true }); }); return Promise.all(rms); }); } /** * Returns the content type and a readable stream of the file */ getEmailAttachment(id, filename) { return __awaiter(this, void 0, void 0, function* () { const mail = yield this.getEmail(id); if (mail.attachments.length === 0) { throw new Error("Email has no attachments"); } for (const attachment of mail.attachments) { if (attachment.generatedFileName === filename) { return attachment; } } throw new Error(`Attachment ${filename} not found`); }); } /** * Download a given email */ getEmailEml(id) { return __awaiter(this, void 0, void 0, function* () { const filename = id + ".eml"; const stream = yield this.getRawEmail(id); return ["message/rfc822", filename, stream]; }); } loadMailsFromDirectory() { return __awaiter(this, void 0, void 0, function* () { const self = this; const persistencePath = yield fs_1.promises.realpath(this.mailDir); const files = yield fs_1.promises.readdir(persistencePath).catch((err) => { logger.error(`Error during reading of the mailDir ${persistencePath}`); throw new Error(`Error during reading of the mailDir ${persistencePath}`); }); this.store.length = 0; const saved = files.map(function (file) { return __awaiter(this, void 0, void 0, function* () { const id = path.parse(file).name; const email = yield getDiskEmail(self.mailDir, id); return saveEmailToStore(self, email); }); }); return Promise.all(saved); }); } } exports.MailServer = MailServer; /** * Handle mailServer error */ function onSmtpError(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; } /** * Create mail directory */ function createMailDir(mailDir) { fs.mkdirSync(mailDir, { recursive: true }); logger.info("MailDev using directory %s", mailDir); } function getDiskEmail(mailDir, id) { return __awaiter(this, void 0, void 0, function* () { const emlPath = path.join(mailDir, id + ".eml"); const data = yield fs_1.promises.readFile(emlPath, "utf8"); const parsedMail = yield (0, mailparser_1.parse)(data); return buildMail(mailDir, id, parsedMail); }); } function buildMail(mailDir, id, parsedMail) { return __awaiter(this, void 0, void 0, function* () { const emlPath = path.join(mailDir, id + ".eml"); const stat = yield fs_1.promises.stat(emlPath); const envelope = { id: id, from: parsedMail.from, to: parsedMail.to, date: parsedMail.date, subject: parsedMail.subject, hasAttachment: parsedMail.attachments.length > 0, isRead: false, }; return Object.assign({ id: envelope.id, envelope, calculatedBcc: (0, bcc_1.calculateBcc)(envelope.to, parsedMail.to, parsedMail.cc), size: stat.size, sizeHuman: utils.formatBytes(stat.size) }, parsedMail); }); } /** * SMTP Server stream and helper functions */ // Save an email object on stream end function saveEmailToStore(mailServer, mail) { return __awaiter(this, void 0, void 0, function* () { var _a; logger.log("Saving email: %s, id: %s", mail.subject, mail.id); mailServer.store.push(mail.envelope); mailServer.eventEmitter.emit("new", mail); const subject = mailServer === null || mailServer === void 0 ? void 0 : mailServer.mailEventSubjectMapper(mail); if (subject) { if (reservedEventName.includes(subject)) { logger.error(`Invalid subject ${subject}; ${reservedEventName} are reserved for internal usage`); } else { mailServer.eventEmitter.emit(subject, mail); } } if ((_a = mailServer === null || mailServer === void 0 ? void 0 : mailServer.outgoing) === null || _a === void 0 ? void 0 : _a.isAutoRelayEnabled()) { yield mailServer.relayMail(mail).catch((err) => { logger.error("Error when relaying email", err); }); } }); } /** * Handle smtp-server onData stream */ function handleDataStream(mailServer, stream, session, callback) { return __awaiter(this, void 0, void 0, function* () { const id = utils.makeId(); const emlStream = fs.createWriteStream(path.join(mailServer.mailDir, id + ".eml")); stream.on("end", function () { emlStream.end(); callback(null, "Message queued as " + id); }); stream.pipe(emlStream); const parsed = yield (0, mailparser_1.parse)(stream); const mail = yield buildMail(mailServer.mailDir, id, parsed); return saveEmailToStore(mailServer, mail); }); }