Im Netz finden sich meistens nur Anleitungen, wie man Bogofilter in einen Exim-Transport einbindet. Leider werden dadurch Spam-E-Mails erst einmal angenommen. Da späteres bouncen durch die meist gefakten Absender-Adressen nicht infrage kommt, steht man vor einem Dilemma. Abhilfe schafft man sich dadurch, dass man Bogofilter in einer local_scan()-Routine noch während des SMTP-Dialogs aufruft und anhand des Ergebnisses die Spam-E-Mail von vorneherein nicht annimmt.
Warum Bogofilter und nicht Spamassassin?
Bogofilter performt erheblich besser als Spamassassin. In der Regel läuft die Überprüfung einer E-Mail in t < 1sec ab. Der Speicherbedarf dürfte ebenfalls geringer sein. Ist Bogofilter gut trainiert, nehmen sich die Erkennungsraten gegenüber Spamassassin ebenfalls nichts.
Exim hat leider keine Schnittstelle, um in acl_smtp_data eine komplette Mail an ein externes Kommando zu pipen und die Rückgabe auszuwerten. Stattdessen muss man eine local_scan()-Funktion schreiben, die dies erledigt.
Aus Bequemlichkeitsgründen habe ich py-exim-localscan verwendet, welches mir die Möglichkeit gibt, ein externes Python-Script über local_scan() laufen zu lassen. Damit vergibt man sich ein wenig Geschwindigkeit und Speichereffizienz, gewinnt aber viel beim Schreiben und Debuggen des Scriptes. Performanceengpässe sind bei meinen ∅ 2 Spam-Mails pro Minute nicht zu beobachten (V-Server mit FreeBSD und 512MB RAM).
Das Script macht nun nichts anderes, als die Nachricht von Exim abzugreifen, Bogofilter drüberlaufen zu lassen, die Rückgabe auszuwerten und entsprechende Werte zurückzuliefern. Dies sind zusätzliche Mailheader, Zeilen fürs Logfile und Returncodes (ACCEPT, REJECT).
Produktiv läuft das Script mit Exim 4.69 und Python 2.6.1. Leider kann ich auf meinem System (FreeBSD 7.1) momentan subprocess.Popen nicht verwenden, da ein Bug dies effektiv verhindert. Daher der Umweg über temporäre Dateien.
# -*- coding: utf-8 import exim, os, re, time, traceback, sys, commands, hashlib, tempfile from collections import defaultdict hostname = os.uname()[1] timestamp = str(time.time()) salt = 'Please replace with some random characters.' debug = False d = [] def local_scan(): try: ret = bogoscan_message() if 'returnheader' in ret: exim.add_header(ret['returnheader']) if 'logline' in ret: exim.log(ret['logline'], ret['logdestination']) save_debug() return ret['local_scan_return'], ret['local_scan_return_message'] except Exception as e: exim.log(str(e), exim.LOG_PANIC) tb = sys.exc_info()[2] tb_lines = traceback.format_list(traceback.extract_tb(tb)) for line in tb_lines: exim.log(line, exim.LOG_PANIC) return exim.LOCAL_SCAN_ACCEPT def bogoscan_message(): header, hashheader = get_header(['message-id', 'x-bogosity']) if already_scanned(hashheader): return {'local_scan_return': exim.LOCAL_SCAN_ACCEPT, 'local_scan_return_message': 'Message already scanned by this host.'} body = get_body() scanresult, classification = get_scan_result(header, body) if classification == 'Spam': if exim.received_protocol == 'local': return {'returnheader': build_returnheader(scanresult, hashheader), 'logline': 'Local injected Message classified as Spam.', 'logdestination': exim.LOG_MAIN, 'local_scan_return': exim.LOCAL_SCAN_ACCEPT, 'local_scan_return_message': 'Local injected Message classified as Spam.'} else: return {'logline': 'Rejected by Bogofilter, %s' % scanresult, 'logdestination': exim.LOG_REJECT, 'local_scan_return': exim.LOCAL_SCAN_REJECT, 'local_scan_return_message': 'Message classified as Spam.'} elif classification == 'Ham' or classification == 'Unsure': return {'returnheader': build_returnheader(scanresult, hashheader), 'local_scan_return': exim.LOCAL_SCAN_ACCEPT, 'local_scan_return_message': 'Message passed through Spam-Filter.'} else: return {'logline': 'Bogofilter sent us an unexspected scanresult.', 'logdestination': exim.LOG_PANIC, 'local_scan_return': exim.LOCAL_SCAN_ACCEPT, 'local_scan_return_message': 'Bogofilter sent us an unexspected scanresult.'} def get_scan_result(header, body): # can't communicate directly over pipes with subprocess.Popen, # until the following is fixed: http://bugs.python.org/issue1731717 message_fd, message_file = tempfile.mkstemp(prefix='message') f = os.fdopen(message_fd, 'w') f.write(header + '\n' + body) f.close() result_fd, result_file = tempfile.mkstemp(prefix='result') os.system('/usr/local/bin/bogofilter -t -M < %s > %s' % (message_file, result_file)) f = os.fdopen(result_fd, 'r') scanresult = f.read().strip() f.close() os.remove(message_file) os.remove(result_file) pattern = re.compile('(Ham|Spam|Unsure)(.*)', re.I) match = pattern.match(scanresult) if match: return scanresult, match.group(1) else: return None, '' def get_header(extractheader): header = [] hashheader = defaultdict(str) pattern = re.compile('(%s):(.*)' % '|'.join(extractheader), re.I | re.S) for h in exim.headers: if h.type != '*': header.append(h.text) match = pattern.match(h.text) if match: hashheader[match.group(1).lower()] = match.group(2).strip() return ''.join(header), hashheader def save_debug(): if debug: debug_fd, debug_file = tempfile.mkstemp(prefix='debug') f = os.fdopen(debug_fd, 'w') f.write('\n'.join(d)) f.close() def get_body(): body = [] while 1: s = os.read(exim.fd, 16384) # steps of 16KByte if not s or len(body) > 64: # feed only the first 1MByte into bogofilter break body.append(s) return ''.join(body) def already_scanned(hashheader): bogoheaderexists, bogoheaderparts = get_bogoheaderparts(hashheader['x-bogosity']) if bogoheaderexists and bogoheaderparts['hash'] != '' and bogoheaderparts['hash'] == get_hash(hashheader, bogoheaderparts): return True else: return False def build_returnheader(scanresult, hashheader): bogoheaderexists, bogoheaderparts = get_bogoheaderparts(scanresult) bogoheaderparts['host'] = hostname bogoheaderparts['timestamp'] = timestamp return 'X-Bogosity: %s, host=%s, timestamp=%s, hash=%s' % (scanresult, hostname, timestamp, get_hash(hashheader, bogoheaderparts)) def get_bogoheaderparts(bogoheader): pattern = re.compile(r'(?P<classification>Ham|Spam|Unsure),(\s+)spamicity=(?P<spamicity>[01]\.\d{6}),(\s+)version=(?P<version>\d\.\d\.\d)(?:,(\s+)host=(?P<host>[a-z\.]+),(\s+)timestamp=(?P<timestamp>\d+\.\d+),(\s+)hash=(?P<hash>[a-f0-9]+))?', re.I | re.S) match = pattern.match(bogoheader) if match: bogoheaderparts = match.groupdict() for key in bogoheaderparts.iterkeys(): if bogoheaderparts[key] == None: bogoheaderparts[key] = '' return True, bogoheaderparts return False, None def get_hash(hashheader, bogoheaderparts): hashparts = [salt, hashheader['message-id'], bogoheaderparts['classification'], bogoheaderparts['spamicity'], bogoheaderparts['version'], bogoheaderparts['host'], bogoheaderparts['timestamp']] return hashlib.sha256('_'.join(hashparts)).hexdigest()