Skip to content

Commit

Permalink
feat: connection pooling, bounce reports
Browse files Browse the repository at this point in the history
  • Loading branch information
titanism committed Sep 6, 2023
1 parent 4b0a13e commit 03ccbb6
Show file tree
Hide file tree
Showing 14 changed files with 614 additions and 284 deletions.
2 changes: 1 addition & 1 deletion app/models/domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ Domains.pre('validate', async function (next) {
const { privateKey, publicKey } = await promisify(crypto.generateKeyPair)(
'rsa',
{
modulusLength: 2048,
modulusLength: 2048, // TODO: support 1024 bit as an option
// default as of nodemailer v6.9.1
hashAlgorithm: 'RSA-SHA256',
publicKeyEncoding: {
Expand Down
15 changes: 15 additions & 0 deletions app/models/logs.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ const MAX_BYTES = bytes('1MB');

// <https://www.mongodb.com/community/forums/t/is-in-operator-able-to-use-index/150459>
const Logs = new mongoose.Schema({
bounce_category: {
type: String,
index: true,
trim: true,
lowercase: true,
default: 'none'
},
hash: {
type: String,
index: true,
Expand Down Expand Up @@ -685,6 +692,7 @@ function getQueryHash(log) {

Logs.pre('validate', function (next) {
try {
// get query hash
this.hash = getQueryHash(this);
next();
} catch (err) {
Expand All @@ -693,6 +701,13 @@ Logs.pre('validate', function (next) {
}
});

Logs.pre('validate', function (next) {
// store bounce info category
if (typeof this?.err?.bounceInfo?.category === 'string')
this.bounce_category = this.err.bounceInfo.category;
next();
});

Logs.pre('save', async function (next) {
// only run this if the document was new
// or if it was run from the parse-logs job
Expand Down
2 changes: 2 additions & 0 deletions helpers/get-error-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ function getErrorCode(err) {
return err.responseCode;

if (
// p-timeout has err.name = "TimeoutError"
err.name === 'TimeoutError' ||
(isSANB(err.code) &&
(DNS_RETRY_CODES.has(err.code) ||
HTTP_RETRY_ERROR_CODES.has(err.code))) ||
Expand Down
185 changes: 185 additions & 0 deletions helpers/get-transporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
const { callbackify } = require('node:util');
const { isIP } = require('node:net');

const _ = require('lodash');
const isFQDN = require('is-fqdn');
const isSANB = require('is-string-and-not-blank');
const ms = require('ms');
const mxConnect = require('mx-connect');
const nodemailer = require('nodemailer');
const pify = require('pify');

const config = require('#config');
const env = require('#config/env');
const isNodemailerError = require('#helpers/is-nodemailer-error');
const isSSLError = require('#helpers/is-ssl-error');
const isTLSError = require('#helpers/is-tls-error');
const parseRootDomain = require('#helpers/parse-root-domain');

const asyncMxConnect = pify(mxConnect);
const maxConnectTime = ms('1m');

const transporterConfig = {
debug: config.env !== 'test',
transactionLog: config.env !== 'test',
// mirrors the queue configuration 60s timeout
connectionTimeout: config.smtpQueueTimeout,
greetingTimeout: config.smtpQueueTimeout,
socketTimeout: config.smtpQueueTimeout,
dnsTimeout: config.smtpQueueTimeout
};

// eslint-disable-next-line complexity
async function getTransporter(connectionMap = new Map(), options = {}, err) {
const {
ignoreMXHosts,
mxLastError,
target,
port,
localAddress,
localHostname,
resolver,
logger,
cache
} = options;

//
// NOTE: in the future we should support duplicate errors possibly re-using the same connection
// (right now the below logic will create a new connection for every smtp error that retries)
//

const key = `${target}:${port}`;

if (!err && connectionMap.has(key)) {
const pool = connectionMap.get(key);
logger.info(`pool found: ${key}`);
return pool;
}

// <https://github.com/zone-eu/mx-connect#configuration-options>
const mx = await asyncMxConnect({
ignoreMXHosts,
mxLastError,
target,
port,
localAddress,
localHostname,
// the default in mx-connect is 300s (5 min)
// <https://github.com/zone-eu/mx-connect/blob/f9e20ceff5a4a7cfb85fba58ca2f040aaa7c2358/lib/get-connection.js#L6>
maxConnectTime,
dnsOptions: {
// NOTE: if we merge code then this will need adjusted
blockLocalAddresses: env.NODE_ENV !== 'test',
// <https://github.com/zone-eu/mx-connect/pull/4>
resolve: callbackify(resolver.resolve.bind(resolver))
},
mtaSts: {
enabled: true,
logger(results) {
logger[results.success ? 'info' : 'error']('MTA-STS', {
results
});
},
cache
}
});

if (!err) {
mx.socket.once('close', () => {
logger.info(`pool closed: ${key}`);
// remove the socket from the available pool
connectionMap.delete(key);
});
}

//
// if the SMTP response was from trusted root host and it was rejected for spam
// then denylist the sender (probably a low-reputation domain name spammer)
//
let truthSource = false;
if (config.truthSources.has(parseRootDomain(target)))
truthSource = parseRootDomain(target);
else if (
_.isObject(mx) &&
isSANB(mx.hostname) &&
isFQDN(mx.hostname) &&
config.truthSources.has(parseRootDomain(mx.hostname))
)
truthSource = parseRootDomain(mx.hostname);

const requireTLS = Boolean(
mx.policyMatch && mx.policyMatch.mode === 'enforce'
);

//
// attempt to send the email with TLS
//
const tls = {
minVersion: requireTLS ? 'TLSv1.2' : 'TLSv1',
// ignore self-signed cert warnings if we are forwarding to a custom port
// (since a lot of sysadmins generate self-signed certs or forget to renew)
rejectUnauthorized: requireTLS && mx.port === 25
};

if (isFQDN(mx.hostname)) tls.servername = mx.hostname;

// <https://github.com/nodemailer/nodemailer/issues/1517>
// <https://gist.github.com/andris9/a13d9b327ea81d620ea89926d2097921>
if (!mx.socket && !isIP(mx.host)) {
try {
const [host] = await resolver.resolve(mx.host);
if (isIP(host)) mx.host = host;
} catch (err) {
logger.error(err);
}
}

// if there was a TLS, SSL, or ECONNRESET then attempt to ignore STARTTLS
const ignoreTLS = Boolean(
!requireTLS &&
err &&
(isNodemailerError(err) ||
isSSLError(err) ||
isTLSError(err) ||
err.code === 'ECONNRESET')
);

const opportunisticTLS = Boolean(!requireTLS && !ignoreTLS);

const isPooling = typeof err === 'undefined' && truthSource;

const transporter = nodemailer.createTransport({
...transporterConfig,
pool: isPooling,
secure: false,
secured: false,
logger,
host: mx.host,
port: mx.port,
connection: mx.socket,
name: localHostname,
requireTLS,
ignoreTLS,
opportunisticTLS,
tls
});

const pool = {
truthSource,
mx,
requireTLS,
ignoreTLS,
opportunisticTLS,
tls,
transporter
};

if (!err && isPooling) {
connectionMap.set(key, pool);
logger.info(`pool created: ${key}`);
}

return pool;
}

module.exports = getTransporter;
8 changes: 7 additions & 1 deletion helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const checkSRS = require('./check-srs');
const createBounce = require('./create-bounce');
const getDiagnosticCode = require('./get-diagnostic-code');
const getBlockedHashes = require('./get-blocked-hashes');
const getTransporter = require('./get-transporter');
const isTLSError = require('./is-tls-error');
const isSSLError = require('./is-ssl-error');

module.exports = {
decrypt,
Expand Down Expand Up @@ -72,5 +75,8 @@ module.exports = {
checkSRS,
createBounce,
getDiagnosticCode,
getBlockedHashes
getBlockedHashes,
getTransporter,
isSSLError,
isTLSError
};
16 changes: 16 additions & 0 deletions helpers/is-ssl-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { boolean } = require('boolean');
const RE2 = require('re2');

const REGEX_SSL_ERR = new RE2(
/ssl routines|ssl23_get_server_hello|\/deps\/openssl|ssl3_check/im
);

function isSSLError(err) {
return boolean(
(typeof err.code === 'string' && err.code.startsWith('ERR_SSL_')) ||
(typeof err.message === 'string' && REGEX_SSL_ERR.test(err.message)) ||
(typeof err.library === 'string' && err.library === 'SSL routines')
);
}

module.exports = isSSLError;
17 changes: 17 additions & 0 deletions helpers/is-tls-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const { boolean } = require('boolean');
const RE2 = require('re2');

const REGEX_TLS_ERR = new RE2(
/disconnected\s+before\s+secure\s+tls\s+connection\s+was\s+established/im
);

function isTLSError(err) {
return boolean(
(typeof err.code === 'string' && err.code === 'ETLS') ||
(typeof err.message === 'string' && REGEX_TLS_ERR.test(err.message)) ||
err.cert ||
(typeof err.code === 'string' && err.code.startsWith('ERR_TLS_'))
);
}

module.exports = isTLSError;
9 changes: 8 additions & 1 deletion helpers/process-email.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,13 @@ function createMtaStsCache(client) {
// `email` is an Email object from mongoose
// `resolver` is a Tangerine instance
// eslint-disable-next-line complexity
async function processEmail({ email, port = 25, resolver, client }) {
async function processEmail({
connectionMap = new Map(),
email,
port = 25,
resolver,
client
}) {
const meta = {
session: createSession(email),
user: email.user,
Expand Down Expand Up @@ -737,6 +743,7 @@ async function processEmail({ email, port = 25, resolver, client }) {
}

const info = await sendEmail({
connectionMap,
session: createSession(email),
cache,
target,
Expand Down
Loading

0 comments on commit 03ccbb6

Please sign in to comment.