# Copyright (C) 2002 by James Henstridge # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, US """Perform spam detection with SpamAssassin. Messages are passed to a spamd (SpamAssassin) daemon for spam checking. Depending on the score returned, messages may be rejected or held for moderation. """ import string import re import socket import HandlerAPI from Mailman import mm_cfg from Mailman import Utils from Mailman.Logging.Syslog import syslog from Hold import hold_for_approval SPAMD_PORT = 0 try: SPAMD_HOST = mm_cfg.SPAMASSASSIN_HOST i = string.find(SPAMD_HOST, ':') if i >= 0: SPAMD_HOST, SPAMD_PORT = SPAMD_HOST[:i], host[i+1:] try: SPAMD_PORT = int(SPAMD_PORT) except: SPAMD_PORT = None except: SPAMD_HOST = 'localhost' if not SPAMD_PORT: SPAMD_PORT = 783 try: DISCARD_SCORE = mm_cfg.SPAMASSASSIN_DISCARD_SCORE except: DISCARD_SCORE = 10 try: HOLD_SCORE = mm_cfg.SPAMASSASSIN_HOLD_SCORE except: HOLD_SCORE = 5 try: MEMBER_BONUS = mm_cfg.SPAMASSASSIN_MEMBER_BONUS except: MEMBER_BONUS = 2 # spamc protocol version PROTOCOL_VERSION = "SPAMC/1.2" class SpamAssassinDiscard(HandlerAPI.DiscardMessage): "The message was matched as spam." class SpamAssassinHold(HandlerAPI.MessageHeld): "SpamAssassin thinks there may be spam in this message." def __init__(self, score=-1, symbols=''): HandlerAPI.MessageHeld.__init__(self) self.score = score self.symbols = string.replace(symbols, ',', ', ') def rejection_notice(self, mlist): return 'Message was held for approval because SpamAssassin gave ' \ 'the message a score of %g for the following reasons:\n\n%s' % \ (self.score, self.symbols) response_pat = re.compile(r'^SPAMD/([\d.]+)\s+(-?\d+)\s+(.*)') check_pat = re.compile(r'^Spam:\s*(True|False)\s*;\s*(-?[\d.]+)\s*/\s*(-?[\d.]+)') def check_message(message): score = -1 symbols = '' try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((SPAMD_HOST, SPAMD_PORT)) header = 'SYMBOLS %s\r\nUser: mailman\r\nContent-length: %s\r\n\r\n' % \ (PROTOCOL_VERSION, len(message)) sock.send(header) sock.send(message) sock.shutdown(1) fd = sock.makefile('r') line = fd.readline() match = response_pat.match(line) if not match: return -1, '' if match.group(2) != '0': syslog('error', 'non zero error code from spamd: %s' % match.group(3)) return -1, '' line = fd.readline() while line and line != '\r\n': match = check_pat.match(line) if match: score = float(match.group(2)) line = fd.readline() # read in the spam report symbols = fd.read() symbols = string.replace(symbols, '\r\n', '\n') except socket.error: pass except IOError: pass return score, symbols def process(mlist, msg, msgdata): if msgdata.get('approved'): return score, symbols = check_message(str(msg)) # optionally give list members a bonus to reduce their chance of # getting held/discarded if MEMBER_BONUS != 0: sender = msg.GetSender() posters = Utils.List2Dict(map(string.lower, mlist.posters)) if mlist.IsMember(sender) or \ Utils.FindMatchingAddresses(sender, posters): score = score - MEMBER_BONUS if score > DISCARD_SCORE: listname = mlist.real_name sender = msg.GetSender() syslog('vette', '%s post from %s discarded: ' 'SpamAssassin score was %s (discard threshold is %s)' % (listname, sender, score, DISCARD_SCORE)) raise SpamAssassinDiscard elif score > HOLD_SCORE: hold_for_approval(mlist, msg, msgdata, SpamAssassinHold(score,symbols))