Skip to content

Commit

Permalink
Merge branch 'master' into greenkeeper/initial
Browse files Browse the repository at this point in the history
  • Loading branch information
msimerson authored Jan 26, 2017
2 parents 938570d + 575395c commit d5cf916
Show file tree
Hide file tree
Showing 5 changed files with 474 additions and 38 deletions.
22 changes: 9 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
# haraka-plugin-domain-counters
# haraka-plugin-known-senders

[![Greenkeeper badge](https://badges.greenkeeper.io/haraka/haraka-plugin-domain-counters.svg)](https://greenkeeper.io/)
[![Greenkeeper badge](https://badges.greenkeeper.io/haraka/haraka-plugin-known-senders.svg)](https://greenkeeper.io/)

Increase the reputation of domains you send email to.


# How it works
## How it works

When you[r users] send emails, this plugin will increment counters for each recipient domain.
When emails later arrive from domains your users have sent email to, reputation engines like
[karma](https://github.com/haraka/haraka-plugin-karma) can observe this and boost their deliverability.
This plugin inspects outgoing emails and adds the destination domains to a known senders database. When emails arrive from those known senders, this plugin stores a result object noting that.

### TL;DR

# PLAN
Outgoing messages are determined by inspecting the `relaying` property of the connection. If `relaying=true`, then the connection has been extended a form of trust, usually via AUTH credentials or IP ACLs. In those outbound emails, the sender domain and recipient domains are parsed and a redis entry is inserted/updated.

- for domains we send to, increment when the message is queued
- for domains we receive from, increment only when:
- message has been queued
When emails later arrive from a domain your users have sent email to, the redis DB is checked and if a match is found, a result object is stored in the transaction results. That result can be scored by reputation engines like
[karma](https://github.com/haraka/haraka-plugin-karma) and used to affect the messages deliverability.

There's no attempt to validate outbound (sent) email domains.

# MULTI-TENANCY

Expand All @@ -45,8 +42,7 @@ This plugin can operate in two contexts where the incoming sender is validated a

## Authentication

Email has several authentication mechanisms that can validate that a sending host
has authority to send on behalf of the [purported] domain:
Email has several authentication mechanisms that can validate that a sending host has authority to send on behalf of the [purported] domain:

- FCrDNS: [Forward Confirmed reverse DNS](https://en.wikipedia.org/wiki/Forward-confirmed_reverse_DNS)
- SPF: [Sender Policy Framework](https://en.wikipedia.org/wiki/Sender_Policy_Framework)
Expand Down
6 changes: 6 additions & 0 deletions config/known-senders.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

[redis]
host = 127.0.0.1
port = 6379
db = 3

278 changes: 259 additions & 19 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,94 @@
'use strict';

var tlds = require('haraka-tld');

exports.register = function () {
var plugin = this;
plugin.inherits('redis');

plugin.load_sender_ini();

plugin.register_hook('queue_ok', 'after_queue');
plugin.register_hook('init_master', 'init_redis_plugin');
plugin.register_hook('init_child', 'init_redis_plugin');

plugin.register_hook('mail', 'is_authenticated');
plugin.register_hook('rcpt_ok', 'check_recipient');
plugin.register_hook('queue_ok', 'update_sender');
plugin.register_hook('data_post', 'is_dkim_authenticated');
}

exports.after_queue = function (next, connection, params) {
exports.load_sender_ini = function () {
var plugin = this;

plugin.cfg = plugin.config.get('known-senders.ini', function () {
plugin.load_sender_ini();
});

plugin.merge_redis_ini();
}

/*
* Outbound Processing
*
* Identify and save to Redis domains the local users send email to
*
* Context: these functions run after a message has been queued.
*
*/

exports.update_sender = function (next, connection, params) {
var plugin = this;
// queue_ok arguments: next, connection, msg
// ok 1390590369 qp 634 (F82E2DD5-9238-41DC-BC95-9C3A02716AD2.1)

plugin.loginfo(plugin, params);
if (!connection) return next();
if (!connection.transaction) return next();
function errNext (err) {
connection.logerror(plugin, 'update_sender: ' + err);
next(null, null, sender_od, rcpt_domains);
}

// connection.loginfo(plugin, params);
if (!connection) return errNext('no connection');
if (!connection.transaction) return errNext('no transaction');
if (!connection.relaying) return next();
var txn = connection.transaction;

var sender_od = plugin.get_sender_domain(txn);
if (!sender_od) return next();
if (!sender_od) return errNext('no sender domain');

var rcpt_domains = plugin.get_recipient_domains(txn);
if (rcpt_domains.length === 0) return next();
if (rcpt_domains.length === 0) {
return errNext('no rcpt ODs for ' + sender_od);
}

plugin.loginfo('sender domain: ' + sender_od);
plugin.loginfo('recip domains: ' + rcpt_domains.join(','));
// within this function, the sender is a local domain
// and the recipient is an external domain
var multi = plugin.db.multi();
for (let i = 0; i < rcpt_domains.length; i++) {
multi.hincrby(sender_od, rcpt_domains[i], 1);
}

next();
multi.exec(function (err, replies) {
if (err) {
connection.logerror(plugin, err);
return next();
}
for (let i = 0; i < rcpt_domains.length; i++) {
connection.loginfo(plugin, 'saved ' + sender_od + ' : ' + rcpt_domains[i] + ' : ' + replies[i]);
}
next(null, null, sender_od, rcpt_domains);
});
}

exports.get_sender_domain = function (txn) {
var plugin = this;

if (!txn.mail_from) return;
if (!txn.mail_from.host) return;
var sender_od = tlds.get_organizational_domain(txn.mail_from.host);
if (txn.mail_from.host !== sender_od) {
plugin.logdebug('sender: ' + txn.mail_from.host + ' -> ' + sender_od);
}
return sender_od;
}

exports.get_recipient_domains = function (txn) {
Expand All @@ -37,23 +101,199 @@ exports.get_recipient_domains = function (txn) {
if (!txn.rcpt_to[i].host) continue;
var rcpt_od = tlds.get_organizational_domain(txn.rcpt_to[i].host);
if (txn.rcpt_to[i].host !== rcpt_od) {
plugin.loginfo('rcpt: ' + txn.rcpt_to[i].host + ' -> ' + rcpt_od);
plugin.logdebug('rcpt: ' + txn.rcpt_to[i].host + ' -> ' + rcpt_od);
}
if (rcpt_domains.indexOf(rcpt_od) === -1) {
// not a duplicate, add to the list
rcpt_domains.push(rcpt_od);
}
}
return rcpt_domains;
}

exports.get_sender_domain = function (txn) {
/*
* Inbound Processing
*
* Look for sender domains we can validate against something. Anything..
* FCrDNS, SPF, DKIM, verified TLS host name, etc..
*
* When verified / validated sender domains are found, check to see if
* their recipients have ever sent mail to their domain.
*/

// early checks, on the mail hook
exports.is_authenticated = function (next, connection, params) {
var plugin = this;

if (!txn.mail_from) return;
if (!txn.mail_from.host) return;
var sender_od = tlds.get_organizational_domain(txn.mail_from.host);
if (txn.mail_from.host !== sender_od) {
plugin.loginfo('sender: ' + txn.mail_from.host + ' -> ' + sender_od);
// only validate inbound messages
if (connection.relaying) return next();

var sender_od = plugin.get_sender_domain(connection.transaction);

if (plugin.has_fcrdns_match(sender_od, connection)) {
connection.loginfo(plugin, '+fcrdns: ' + sender_od);
return next(null, null, sender_od);
}
return sender_od;
}
if (plugin.has_spf_match(sender_od, connection)) {
connection.loginfo(plugin, '+spf: ' + sender_od);
return next(null, null, sender_od);
}

// TODO: TLS verified domain?

return next();
}

exports.get_sender_od = function (connection) {
var plugin = this;
if (!connection) return;
if (!connection.transaction) return;
var txn_res = connection.transaction.results.get(plugin.name);
if (!txn_res) return;
return txn_res.sender;
}

exports.get_rcpt_ods = function (connection) {
var plugin = this;
if (!connection) return;
if (!connection.transaction) return;

var txn_r = connection.transaction.results.get(plugin.name);
if (!txn_r) return;

return txn_r.rcpt_ods;
}

function already_matched (connection) {
var res = connection.transaction.results.get({ name: 'known-senders'});
if (!res) return false;
return (res.pass && res.pass.length) ? true : false;
}

exports.check_recipient = function (next, connection, rcpt) {
var plugin = this;
// rcpt is a valid local email address. Some rcpt_to.* plugin has
// accepted it.

// inbound only
if (connection.relaying) return next();

function errNext (err) {
connection.logerror(plugin, 'check_recipient: ' + err);
next();
}

if (!rcpt.host) return errNext('rcpt.host unset?');

// reduce the host portion of the email address to an OD
var rcpt_od = tlds.get_organizational_domain(rcpt.host);
if (!rcpt_od) return errNext('no rcpt od for ' + rcpt.host);

connection.transaction.results.push(plugin, { rcpt_ods: rcpt_od });

// if no validated sender domain, there's nothing to do...yet
var sender_od = plugin.get_sender_od(connection);
if (!sender_od) return next();

// The sender OD is validated, check Redis for a match
plugin.db.hget(rcpt_od, sender_od, function (err, reply) {
if (err) {
plugin.logerror(err);
return next();
}
connection.loginfo(plugin, rcpt_od + ' : ' + sender_od + ' : ' + reply);
if (reply) {
connection.transaction.results.add(plugin, { pass: rcpt_od, count: reply });
}
return next(null, null, rcpt_od);
});
}

exports.is_dkim_authenticated = function (next, connection) {
var plugin = this;
if (connection.relaying) return next();

function errNext (err) {
connection.logerror(plugin, 'is_dkim_authenticated: ' + err);
return next(null, null, rcpt_ods);
}

if (already_matched(connection)) return errNext('already matched');

var sender_od = plugin.get_sender_od(connection);
if (!sender_od) return errNext('no sender_od');

var rcpt_ods = plugin.get_rcpt_ods(connection);
if (!rcpt_ods || ! rcpt_ods.length) return errNext('no rcpt_ods');

var dkim = connection.transaction.results.get('dkim_verify');
if (!dkim) return errNext('no dkim_verify results');
if (!dkim.pass || !dkim.pass.length) return errNext('no dkim pass');

var multi = plugin.db.multi();

for (let i = 0; i < dkim.pass.length; i++) {
var dkim_od = tlds.get_organizational_domain(dkim.pass[i]);
if (dkim_od === sender_od) {
connection.transaction.results.add(plugin, { sender: sender_od, auth: 'dkim' });
for (let j = 0; j < rcpt_ods.length; j++) {
multi.hget(rcpt_ods[j], sender_od);
}
}
}

multi.exec(function (err, replies) {
if (err) {
connection.logerror(plugin, err);
return next();
}

for (let j = 0; j < rcpt_ods.length; j++) {
connection.loginfo(plugin, rcpt_ods[j] + ' : ' + sender_od + ' : ' + replies[j]);
if (replies[j]) {
connection.transaction.results.add(plugin, { pass: rcpt_ods[j], count: replies[j] });
}
}
return next(null, null, rcpt_ods);
});
}

exports.has_fcrdns_match = function (sender_od, connection) {
var plugin = this;
var fcrdns = connection.results.get('fcrdns');
if (!fcrdns) return false;
if (!fcrdns.fcrdns) return false;

connection.loginfo(plugin, fcrdns.fcrdns);

var fcrdns_od = tlds.get_organizational_domain(fcrdns.fcrdns);
if (fcrdns_od !== sender_od) return false;

connection.transaction.results.add(plugin, {sender: sender_od, auth: 'fcrdns'});
return true;
}

exports.has_spf_match = function (sender_od, connection) {
var plugin = this;

var spf = connection.results.get('spf');
if (spf && spf.domain && spf.result === 'Pass') {
// scope=helo (HELO/EHLO)
if (tlds.get_organizational_domain(spf.domain) === sender_od) {
connection.transaction.results.add(plugin, {sender: sender_od});
return true;
}
}

spf = connection.transaction.results.get('spf');
if (spf && spf.domain && spf.result === 'Pass') {
// scope=mfrom (HELO/EHLO)
if (tlds.get_organizational_domain(spf.domain) === sender_od) {
connection.transaction.results.add(plugin, {sender: sender_od, auth: 'spf' });
return true;
}
}

// this.loginfo(spf);
return false;
}
Loading

0 comments on commit d5cf916

Please sign in to comment.