Use Mailgun/SendGrid for free inbound SPAM filtering

Why?

Although not problematic for most commercial Email providers(GMail, Outlook, etc.), self-hosted Email are prone to inbound SPAM.

A couple solutions exist: the most common one is to use local Spamassassin - but training the model takes time and there's no good corpse available - and spammers are much smarter.

There are a handful of hosted Inbound SPAM filtering solutions but are quite expensive:

  • Mailchannels - $20/month/5 domains, then hikes to $507/mth/1000 domains.
  • Spamtitan - pricing is "quotation only".
  • Spydermail - $1.99/mailbox/month, starting from 15 mailboxes.
  • MXguarddog - $0.25/mailbox/month.
  • Spamhero - per mailbox per mail pricing.
  • McAfee: pricing not available.

Use Mailgun

Mailgun is included in Github student package. Flex plan provides 1000 Emails/month for free.

Code of connector:

import os

from flask import Flask, request, jsonify
from imap_tools import MailBox

app = Flask(__name__)

# HTTP param > environment variable > default

IMAP_USERNAME = os.getenv('IMAP_USERNAME', '')
IMAP_PASSWORD = os.getenv('IMAP_PASSWORD', '')
IMAP_SERVER = os.getenv('IMAP_SERVER', '')

MAILGUN_ANTISPAM_USE_BOOL = int(os.getenv('MAILGUN_ANTISPAM_USE_BOOL', '0') or 0)
MAILGUN_ANTISPAM_SSCORE_CUTOFF = int(os.getenv('MAILGUN_ANTISPAM_SSCORE_CUTOFF', '20') or 20)

def get_target_mailbox(data, use_bool=MAILGUN_ANTISPAM_USE_BOOL, sscore_cutoff=MAILGUN_ANTISPAM_SSCORE_CUTOFF):
    """
    Return target mailbox from the data received from Mailgun.

    See https://documentation.mailgun.com/en/latest/user_manual.html#spam-filter
    for more details.

    MAILGUN_ANTISPAM_USE_BOOL suppresses MAILGUN_ANTISPAM_CUTOFF.

    :param junk_threshold: threshold for junk mail according to Mailgun
    :type junk_threshold: float
    :param data: mail data from Mailgun
    :type data: dict
    :return: INBOX/Junk
    :rtype: str
    """
    # No = Not spam, Yes = Spam
    # "At the time of writing this, we are filtering spam at a score of around 5.0 but we are constantly calibrating
    # this."
    if use_bool:
        if data['X-Mailgun-Sflag'][0] == 'No':
            return 'INBOX'
        return 'Junk'

    # lower = less likely to be spam
    # negative = very unlikely to be spam
    # > 20 is very likely to be spam
    if float(data['X-Mailgun-Sscore'][0]) < sscore_cutoff:
        return 'INBOX'
    return 'Junk'

@app.route('/post_mime', methods=['POST'])
def post_mime():
    # TODO: check if the request is from Mailgun
    data = request.form.to_dict(flat=False)

    # HTTP param suppresses everything else
    imap_username = request.args.get('username', IMAP_USERNAME)
    imap_password = request.args.get('password', IMAP_PASSWORD)
    imap_server = request.args.get('server', IMAP_SERVER)
    use_bool = int(request.args.get('use_bool', MAILGUN_ANTISPAM_USE_BOOL))
    sscore_cutoff = int(request.args.get('sscore_cutoff', MAILGUN_ANTISPAM_SSCORE_CUTOFF))

    # decide whether incoming mail is spam or not
    try:
        target_mailbox = get_target_mailbox(data, use_bool, sscore_cutoff)
    except Exception as e:
        return jsonify({'error': 'Cannot decide target: ' + str(e)}), 500

    # directly deliver to target mailbox
    try:
        with MailBox(imap_server).login(imap_username, imap_password) as mailbox:
            msg = '\n'.join(data['body-mime'])
            mailbox.append(msg.encode(), target_mailbox, dt=None)
    except Exception as e:
        return jsonify({'error': str(e), 'status': -1}), 500

    return jsonify({'status': 0})

if __name__ == '__main__':
    app.run(host='localhost', port=8000, debug=True)

Setup environment variables as follows:

Name    Value   

IMAP_PASSWORD   your password

IMAP_SERVER the server  

IMAP_USERNAME   login   

LANG    en_US.UTF-8 

LC_ALL  en_US.UTF-8 

LC_LANG en_US.UTF-8 

MAILGUN_ANTISPAM_SSCORE_CUTOFF  20  

MAILGUN_ANTISPAM_USE_BOOL   1   

PYTHONIOENCODING    UTF-8

Go to Mailgun, setup a Route with catch all forwarding to https://your-binded-domain/post_mime .

Leave a Reply

Your email address will not be published. Required fields are marked *