2 # -*- coding: iso-8859-1 -*-
5 # Noname - Mail filter to replace procmail.
6 # Copyright (C) 2004 Frédéric Jolliton <frederic@jolliton.com>
8 # This program is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 2 of the License, or
11 # (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program; if not, write to the Free Software
20 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
32 from os import EX_OK, EX_NOUSER, EX_TEMPFAIL, EX_DATAERR
38 # Path to subprocess module. Ideally not needed if subprocess
39 # (formerly popen5) is installed into /site-packages/ directory.
41 #sys.path.insert( 0 , '/usr/local/lib/python/' )
44 # subprocess (formerly popen5) - See PEP 324
45 # http://www.lysator.liu.se/~astrand/popen5/
47 # >>> cat = subprocess.Popen( 'cat' , stdin = subprocess.PIPE , stdout = subprocess.PIPE )
48 # >>> cat.communicate( 'bla' )
57 import popen5 as subprocess
59 print 'Please install subprocess module.'
60 print 'See http://www.lysator.liu.se/~astrand/popen5/.'
62 #--[ Configuration variables ]------------------------------------------------
65 # For which users receiving a mail should we send a UDP packet.
67 g_userNotificationFilter = [ 'fred' ]
70 # For which IP address should we send the notification.
71 # (Can include broadcast address.)
73 g_notificationAddresses = [ '192.168.1.255' ]
76 # On which port should we send the notification.
78 g_notificationPort = 23978
81 # Max mail size to be processed by this script.
83 # Larger mail are just not filtered.
85 g_maxMailSize = 2 * 1024 * 1024
88 # Where to save copy of mail in case of error.
90 g_directoryBackup = '/var/mail.filter/recovery/'
93 # Where to find rules about each user.
95 # Filename for user 'joe' will be named 'joe.mf' in that
96 # directory. If the file doesn't exist, no filtering is
97 # done (not even spam/virus filtering.)
99 g_directoryRules = '/var/mail.filter/rules/'
102 # Path to spamprobe binary.
104 g_pathSpamProbe = '/usr/bin/spamprobe'
107 # Path to ClamAV binary.
109 # Could point either to clamdscan or clamscan.
111 # The first one is *HIGHLY* recommended since
112 # it will use the ClamAV daemon.
114 g_pathClamdscan = '/usr/bin/clamdscan'
117 # Path to Cyrus's deliver binary.
119 g_pathCyrusDeliver = '/usr/cyrus/bin/deliver'
122 # Filename where to put log.
124 g_pathLog = '/var/log/mail.filter.log'
126 #--[ Global variables ]-------------------------------------------------------
129 # Should the log be also printed on stdout ?
131 g_copyLogToStdout = False
134 # Don't actually feed the mail to Cyrus.
139 # The user name of the recipient.
144 # The current mail as string (as read from stdin.)
149 # The current mail as email.Message.Message object.
153 #-----------------------------------------------------------------------------
156 # Utility function to return traceback as string from most recent
161 import traceback, sys
162 return ''.join( traceback.format_exception( *sys.exc_info() ) )
165 # Return (rc, output)
167 def pipe( cmd , input ) :
169 p = subprocess.Popen( cmd , stdin = subprocess.PIPE , stdout = subprocess.PIPE , stderr = subprocess.PIPE )
170 # much faster than passing 'input' to communicate directly..
172 p.stdin.write( input )
176 return p.returncode , r[ 0 ] , r[ 1 ]
179 # Return an ISO-8661 date representation in the UTC
182 def timestamp( t = None ) :
186 return '%04d-%02d-%02dT%02d:%02d:%02dZ' % t[ : 6 ]
191 def logMessage( msg ) :
193 logMessage.__dict__.setdefault( 'logFile' , None )
195 if not logMessage.logFile and not g_testMode :
197 # If log file is not yet open, try to open it.
200 logMessage.logFile = open( g_pathLog , 'a+' )
204 msg = msg.splitlines()
205 prefix = timestamp() + ' [%s] ' % os.getpid()
208 # Output to log file.
210 if logMessage.logFile :
215 logMessage.logFile.write( line + '\n' )
216 logMessage.logFile.flush()
221 # Output to standard output.
223 if g_copyLogToStdout :
227 sys.stdout.write( line + '\n' )
231 # Make a backup of the mail (in case it's impossible
232 # to store the mail to Cyrus.)
234 def backup( filenamePrefix = None ) :
237 logMessage( 'TEST MODE: Backup of the mail.' )
241 # Ensure directory exist
243 os.makedirs( g_directoryBackup )
248 basename += filenamePrefix + '-'
249 basename += '%.3f' % time.time()
250 fn = g_directoryBackup + '/' + basename
252 f = open( fn , 'a+' )
253 f.write( g_mailText )
256 logMessage( 'Error saving backup copy.' )
258 logMessage( 'Message appended to backup directory as `%s\'.' % basename )
263 # Packet payload contains:
265 # <username> + char( 0 ) + <foldername> + char( 0 )
267 def notifyDeliver( user , folder ) :
269 if user not in g_userNotificationFilter :
273 s = socket.socket( socket.AF_INET , socket.SOCK_DGRAM )
274 msg = user + chr( 0 ) + folder + chr( 0 )
275 for address in g_notificationAddresses :
276 s.sendto( msg , ( address , g_notificationPort ) )
281 # Deliver a mail to Cyrus for user 'username' in
282 # folder 'folderName' (or default folder if not
285 def deliverTo( username , folderName = None ) :
288 pseudoFolderName = 'INBOX'
289 folderName = 'user.' + username
291 pseudoFolderName = 'INBOX.' + folderName
292 folderName = 'user.' + username + '.' + folderName
295 logMessage( 'TEST MODE: Delivering mail in `%s\'.' % ( folderName , ) )
299 # Build the command line for running deliver.
301 cmd = [ g_pathCyrusDeliver ]
302 cmd += [ '-a' , username ]
303 cmd += [ '-m' , folderName ]
306 rc , stdout , stderr = pipe( cmd , g_mailText )
308 logMessage( 'Error running `%s\': %s.' % ( cmd[ 0 ] , e[ 1 ] ) )
312 logMessage( 'Message delivered in folder `%s\'.' % folderName )
313 notifyDeliver( username , pseudoFolderName )
315 errorMessage = stdout.rstrip()
317 # Extract raw error message
321 # +user.fred: Message contains invalid header
323 m = errorMessage.split( ': ' , 1 )
329 if m == 'Message contains invalid header' :
332 elif m == 'Mailbox does not exist' :
335 logMessage( 'Refused by Cyrus: [%s] `%s\'.' % ( rcMsg , errorMessage ) )
338 #-----------------------------------------------------------------------------
341 # Return virus list from the output of ClamAV.
343 def extractVirusList( clamdOutput ) :
346 for line in clamdOutput.splitlines() :
347 r = extractVirusList.reClamdVirus.search( line.rstrip() )
348 if r == None : continue
349 res.append( r.group( 1 ) )
352 extractVirusList.reClamdVirus = re.compile( r'^[^:]+: (\S+) FOUND$' )
357 # Return True if mail is clean.
359 def antivirusScan() :
361 cmd = [ g_pathClamdscan , '-' ]
362 rc , stdout , stderr = pipe( cmd , g_mailText )
363 output = stderr or ''
364 #logMessage( 'clamdscan returned %s' % rc )
366 raise 'Unable to scan for viruses (%s)' % cmd
370 viruses = extractVirusList( output )
372 msg += ' [%s]' % ' '.join( viruses )
379 # Return True if mail is correct.
383 if not g_user : return True
385 cmd = [ g_pathSpamProbe ]
386 cmd += [ '-d' , '/var/spamprobe/db/%s/' % g_user ]
388 rc , stdout , stderr = pipe( cmd , g_mailText )
389 r = ( stdout or '' ).split()
390 return r[ 0 ] != 'SPAM'
392 #-----------------------------------------------------------------------------
394 def readUserRules( user ) :
398 f = open( g_directoryRules + '/' + user + '.mf' )
401 return confparser.parse( f.read() )
403 def ruleMatch( header , matchType , text ) :
405 if matchType == 'match' :
407 return re.search( text , header , re.I ) != None
409 logMessage( 'Error with regex `%s\' from %s\'s user configuration.' % ( text , g_user ) )
411 elif matchType == 'is' :
412 return header.strip().lower() == text.strip().lower()
413 elif matchType == 'contains' :
414 return header.lower().find( text.strip().lower() ) != -1
416 logMessage( 'Unknown match type `%s\' from %s\'s user configuration.' % ( matchType , g_user ) )
419 def testRule( rule ) :
423 for subrule in rule[ 2 ] :
424 if testRule( subrule ) == False :
429 for subrule in rule[ 2 ] :
430 if testRule( subrule ) == True :
433 for subrule in rule[ 2 ] :
434 if testRule( subrule ) == True :
438 elif cmd == 'header' :
441 headerName , matchType , text = args
442 header = g_mail[ headerName ] or ''
443 header = header.replace( '\n' , ' ' )
444 if ruleMatch( header , matchType , text ) :
446 elif cmd == 'broken' :
449 elif cmd == 'infected' :
451 return not antivirusScan()
454 return not spamScan()
456 logMessage( 'Unknown rule name `%s\'.' % ( cmd , ) )
459 #-----------------------------------------------------------------------------
461 def errorNameToErrorCode( code ) :
464 if code == 'nouser' :
466 elif code == 'tempfail' :
468 elif code == 'dataerr' :
477 # Find the destination folder for user 'user' according to rules defined for
478 # him/her against the current mail.
482 # ( False , mailBoxName | None )
483 # ( True , customErrorCode )
485 def getDestFolder( user ) :
487 conf = readUserRules( user )
491 logMessage( 'No rules defined for user `%s\'.' % user )
492 return ( False , None )
496 logMessage( 'Empty rules set or syntax error encountered for user `%s\'.' % user )
497 return ( False , None )
499 for item in conf[ 2 ] :
501 action , args , subs = item[ : 3 ]
503 if action not in [ 'folder' , 'reject' ] : continue
507 # First rule that match is used.
509 if testRule( rule ) :
514 if action == 'folder' :
515 return ( False , args[ 0 ] )
517 if action == 'reject' :
519 return ( True , errorNameToErrorCode( args[ 0 ] ) )
521 logMessage( 'Invalid reject code %r.' % args[ 0 ] )
523 return ( False , None )
525 #-----------------------------------------------------------------------------
528 # Dispatch the mail to the correct folder (deduced from rules.)
532 # ( False , errorCode )
533 # ( True , customErrorCode )
537 isCustomErrorCode , value = getDestFolder( g_user )
542 if isCustomErrorCode :
543 return ( True , value )
546 # We got a folder name (or None for default folder.)
550 if mbox : pseudoName += '.' + mbox
553 # Try to deliver in the named folder
555 rc = deliverTo( g_user , mbox )
557 # If we get an error code, then we deliver the mail to default folder,
558 # except if the error was "data error" or if we already tried to deliver
559 # it to default folder.
561 if rc not in [ EX_OK , EX_DATAERR ] and mbox :
562 logMessage( 'Error delivering to folder %s of user `%s\'.' % ( pseudoName , g_user ) )
563 logMessage( 'Mail will go into default folder.' )
564 rc = deliverTo( g_user )
568 # Here we also handle the case of EX_DATAERR not handled above.
571 logMessage( 'Error delivering to default folder of user `%s\'.' % ( g_user , ) )
573 # All errors code different from "data error" are translated to
574 # "no user" error code.
576 if rc != EX_DATAERR :
579 return ( False , rc )
581 #-----------------------------------------------------------------------------
585 print '''Usage: mail.filter [OPTIONS] [username]
587 -h, --help Print this help.
588 -v, --verbose Verbose mode, output log to stdout.
589 -t, --test Test mode. Don't feed the mail to Cyrus, and don't
590 write anything into log file.
591 -l, --log=FILENAME Set log filename (default: %s).
592 -r, --rules=DIRECTORY Directory where are located users rules (default %s).
594 Report bugs to <fj@tuxee.net>.''' \
595 % ( g_pathLog , g_directoryRules )
599 global g_user, g_mail, g_mailText, g_copyLogToStdout, g_pathLog, g_directoryRules , g_testMode
601 options , parameters = \
602 getopt.getopt( sys.argv[ 1 : ] ,
604 ( 'help' , 'verbose' , 'test' , 'log=' , 'rules=' ) )
606 for option , argument in options :
607 if option in [ '-h' , '--help' ] :
610 elif option in [ '-v' , '--verbose' ] :
611 g_copyLogToStdout = True
612 elif option in [ '-t' , '--test' ] :
614 elif option in [ '-l' , '--log' ] :
616 elif option in [ '-r' , '--rules' ] :
617 g_directoryRules = argument
620 # At most one parameter expected.
622 if len( parameters ) > 1 :
624 # We just log a error message. We continue to proceed
625 # to not lost the mail !
627 logMessage( 'Warning: More than one parameter on command line.' )
630 g_user = parameters[ 0 ]
632 logMessage( 'Running mail.filter for user `%s\'.' % g_user )
635 # FIXME: Should we be reading the mail by block, so that
636 # we can at least read and backup a part of the standard input
637 # in case an error occur ? (broken pipe for example)
640 g_mailText = sys.stdin.read()
642 logMessage( getTraceBack() )
648 if len( g_mailText ) > g_maxMailSize :
649 logMessage( 'Message too big (%s bytes). Not filtering it.' % len( g_mailText ) )
652 ok = deliverTo( g_user )
653 logMessage( 'Unable to deliver it to user `%s\'.' % g_user )
662 g_mail = email.message_from_string( g_mailText , strict = False )
664 logMessage( getTraceBack() )
670 logMessage( 'No user specified.' )
675 # Return code default to "temporary failure".
677 isCustomErrorCode , rc = ( False , EX_TEMPFAIL )
683 isCustomErrorCode , rc = dispatch()
685 logMessage( getTraceBack() )
687 if not isCustomErrorCode :
689 if rc not in [ EX_OK , EX_DATAERR ] :
690 logMessage( 'Rescue mode - Trying to deliver to default folder of user %s.' % g_user )
691 rc = deliverTo( g_user )
695 if rc != EX_DATAERR :
697 logMessage( 'Exit code is %d.' % rc )
705 logMessage( 'Custom exit code is %d.' % rc )
709 if __name__ == '__main__' :