@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
JavaScript
"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);
});
}