003b1648294111f587ad6e7f2a5d401c529d443e
[confparser-old] / mail.filter
1 #!/usr/bin/python
2 # -*- coding: iso-8859-1 -*-
3
4 #
5 # Noname - Mail filter to replace procmail.
6 # Copyright (C) 2004  Frédéric Jolliton <frederic@jolliton.com>
7 #
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.
12 #
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.
17 #
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
21 #
22
23 #
24 # TODO:
25 #
26 #   No todo.
27 #
28
29 import sys
30 import getopt
31 import os
32 from os import EX_OK, EX_NOUSER, EX_TEMPFAIL, EX_DATAERR
33 import time
34 import email
35 import re
36
37 #
38 # Path to subprocess module. Ideally not needed if subprocess
39 # (formerly popen5) is installed into /site-packages/ directory.
40 #
41 #sys.path.insert( 0 , '/usr/local/lib/python/' )
42
43 #
44 # subprocess (formerly popen5) - See PEP 324
45 # http://www.lysator.liu.se/~astrand/popen5/
46 #
47 # >>> cat = subprocess.Popen( 'cat' , stdin = subprocess.PIPE , stdout = subprocess.PIPE )
48 # >>> cat.communicate( 'bla' )
49 # ('bla', None)
50 # >>> cat.returncode
51 # 0
52 #
53 try :
54         import subprocess
55 except ImportError :
56         try :
57                 import popen5 as subprocess
58         except ImportError :
59                 print 'Please install subprocess module.'
60                 print 'See http://www.lysator.liu.se/~astrand/popen5/.'
61
62 #--[ Configuration variables ]------------------------------------------------
63
64 #
65 # For which users receiving a mail should we send a UDP packet.
66 #
67 g_userNotificationFilter = [ 'fred' ]
68
69 #
70 # For which IP address should we send the notification.
71 # (Can include broadcast address.)
72 #
73 g_notificationAddresses = [ '192.168.1.255' ]
74
75 #
76 # On which port should we send the notification.
77 #
78 g_notificationPort = 23978
79
80 #
81 # Max mail size to be processed by this script.
82 #
83 # Larger mail are just not filtered.
84 #
85 g_maxMailSize = 2 * 1024 * 1024
86
87 #
88 # Where to save copy of mail in case of error.
89 #
90 g_directoryBackup  = '/var/mail.filter/recovery/'
91
92 #
93 # Where to find rules about each user.
94
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.)
98 #
99 g_directoryRules   = '/var/mail.filter/rules/'
100
101 #
102 # Path to spamprobe binary.
103 #
104 g_pathSpamProbe    = '/usr/bin/spamprobe'
105
106 #
107 # Path to ClamAV binary.
108 #
109 # Could point either to clamdscan or clamscan.
110 #
111 # The first one is *HIGHLY* recommended since
112 # it will use the ClamAV daemon.
113 #
114 g_pathClamdscan    = '/usr/bin/clamdscan'
115
116 #
117 # Path to Cyrus's deliver binary.
118 #
119 g_pathCyrusDeliver = '/usr/cyrus/bin/deliver'
120
121 #
122 # Filename where to put log.
123 #
124 g_pathLog          = '/var/log/mail.filter.log'
125
126 #--[ Global variables ]-------------------------------------------------------
127
128 #
129 # Should the log be also printed on stdout ?
130 #
131 g_copyLogToStdout = False
132
133 #
134 # Don't actually feed the mail to Cyrus.
135 #
136 g_testMode = False
137
138 #
139 # The user name of the recipient.
140 #
141 g_user = None
142
143 #
144 # The current mail as string (as read from stdin.)
145 #
146 g_mailText = None
147
148 #
149 # The current mail as email.Message.Message object.
150 #
151 g_mail = None
152
153 #-----------------------------------------------------------------------------
154
155 #
156 # Utility function to return traceback as string from most recent
157 # exception.
158 #
159 def getTraceBack() : 
160  
161         import traceback, sys 
162         return ''.join( traceback.format_exception( *sys.exc_info() ) )
163
164 #
165 # Return (rc, output)
166 #
167 def pipe( cmd , input ) :
168
169         p = subprocess.Popen( cmd , stdin = subprocess.PIPE , stdout = subprocess.PIPE , stderr = subprocess.PIPE )
170         # much faster than passing 'input' to communicate directly..
171         try :
172                 p.stdin.write( input )
173         except IOError :
174                 pass
175         r = p.communicate()
176         return p.returncode , r[ 0 ] , r[ 1 ]
177
178 #
179 # Return an ISO-8661 date representation in the UTC
180 # timezone.
181 #
182 def timestamp( t = None ) :
183
184         if t == None :
185                 t = time.gmtime()
186         return '%04d-%02d-%02dT%02d:%02d:%02dZ' % t[ : 6 ]
187
188 #
189 # Log message 'msg'.
190 #
191 def logMessage( msg ) :
192
193         logMessage.__dict__.setdefault( 'logFile' , None )
194
195         if not logMessage.logFile and not g_testMode :
196                 #
197                 # If log file is not yet open, try to open it.
198                 #
199                 try :
200                         logMessage.logFile = open( g_pathLog , 'a+' )
201                 except :
202                         return
203
204         msg = msg.splitlines()
205         prefix = timestamp() + ' [%s] ' % os.getpid()
206
207         #
208         # Output to log file.
209         #
210         if logMessage.logFile :
211
212                 for line in msg :
213                         line = prefix + line
214                         try :
215                                 logMessage.logFile.write( line + '\n' )
216                                 logMessage.logFile.flush()
217                         except :
218                                 pass
219
220         #
221         # Output to standard output.
222         #
223         if g_copyLogToStdout :
224
225                 for line in msg :
226                         line = prefix + line
227                         sys.stdout.write( line + '\n' )
228                         sys.stdout.flush()
229
230 #
231 # Make a backup of the mail (in case it's impossible
232 # to store the mail to Cyrus.)
233 #
234 def backup( filenamePrefix = None ) :
235
236         if g_testMode :
237                 logMessage( 'TEST MODE: Backup of the mail.' )
238                 return
239
240         try :
241                 # Ensure directory exist
242                 import os
243                 os.makedirs( g_directoryBackup )
244         except :
245                 pass
246         basename = ''
247         if filenamePrefix :
248                 basename += filenamePrefix + '-'
249         basename += '%.3f' % time.time()
250         fn = g_directoryBackup + '/' + basename
251         try :
252                 f = open( fn , 'a+' )
253                 f.write( g_mailText )
254                 f.close()
255         except :
256                 logMessage( 'Error saving backup copy.' )
257         else :
258                 logMessage( 'Message appended to backup directory as `%s\'.' % basename )
259
260 #
261 # Experimental !
262 #
263 # Packet payload contains:
264 #
265 # <username> + char( 0 ) + <foldername> + char( 0 )
266 #
267 def notifyDeliver( user , folder ) :
268
269         if user not in g_userNotificationFilter :
270                 return
271         try :
272                 import socket
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 ) )
277         except :
278                 pass
279
280 #
281 # Deliver a mail to Cyrus for user 'username' in
282 # folder 'folderName' (or default folder if not
283 # specified.)
284 #
285 def deliverTo( username , folderName = None ) :
286
287         if not folderName :
288                 pseudoFolderName = 'INBOX'
289                 folderName = 'user.' + username
290         else :
291                 pseudoFolderName = 'INBOX.' + folderName
292                 folderName = 'user.' + username + '.' + folderName
293
294         if g_testMode :
295                 logMessage( 'TEST MODE: Delivering mail in `%s\'.' % ( folderName , ) )
296                 return EX_OK
297
298         #
299         # Build the command line for running deliver.
300         #
301         cmd = [ g_pathCyrusDeliver ]
302         cmd += [ '-a' , username ]
303         cmd += [ '-m' , folderName ]
304
305         try :
306                 rc , stdout , stderr = pipe( cmd , g_mailText )
307         except OSError , e :
308                 logMessage( 'Error running `%s\': %s.' % ( cmd[ 0 ] , e[ 1 ] ) )
309                 return EX_TEMPFAIL
310
311         if rc == EX_OK :
312                 logMessage( 'Message delivered in folder `%s\'.' % folderName )
313                 notifyDeliver( username , pseudoFolderName )
314         else :
315                 errorMessage = stdout.rstrip()
316                 #
317                 # Extract raw error message
318                 #
319                 # Example of output:
320                 #
321                 #   +user.fred: Message contains invalid header
322                 #
323                 m = errorMessage.split( ': ' , 1 )
324                 if len( m ) == 2 :
325                         m = m[ 1 ]
326                 else :
327                         m = None
328                 rcMsg = '%d' % rc
329                 if m == 'Message contains invalid header' :
330                         rc = EX_DATAERR
331                         rcMsg += '->%d' % rc
332                 elif m == 'Mailbox does not exist' :
333                         rc = EX_NOUSER
334                         rcMsg += '->%d' % rc
335                 logMessage( 'Refused by Cyrus: [%s] `%s\'.' % ( rcMsg , errorMessage ) )
336         return rc
337
338 #-----------------------------------------------------------------------------
339
340 #
341 # Return virus list from the output of ClamAV.
342 #
343 def extractVirusList( clamdOutput ) :
344
345         res = []
346         for line in clamdOutput.splitlines() :
347                 r = extractVirusList.reClamdVirus.search( line.rstrip() )
348                 if r == None : continue
349                 res.append( r.group( 1 ) )
350         return res
351
352 extractVirusList.reClamdVirus = re.compile( r'^[^:]+: (\S+) FOUND$' )
353
354 #
355 # Check for virus.
356 #
357 # Return True if mail is clean.
358 #
359 def antivirusScan() :
360
361         cmd = [ g_pathClamdscan , '-' ]
362         rc , stdout , stderr = pipe( cmd , g_mailText )
363         output = stderr or ''
364         #logMessage( 'clamdscan returned %s' % rc )
365         if rc == 2 :
366                 raise 'Unable to scan for viruses (%s)' % cmd
367         ok = not rc
368         if not ok :
369                 msg = 'Virus found.'
370                 viruses = extractVirusList( output )
371                 if viruses :
372                         msg += ' [%s]' % ' '.join( viruses )
373                 logMessage( msg )
374         return ok
375
376 #
377 # Check for spam.
378 #
379 # Return True if mail is correct.
380 #
381 def spamScan() :
382
383         if not g_user : return True
384
385         cmd = [ g_pathSpamProbe ]
386         cmd += [ '-d' , '/var/spamprobe/db/%s/' % g_user ]
387         cmd += [ 'receive' ]
388         rc , stdout , stderr = pipe( cmd , g_mailText )
389         r = ( stdout or '' ).split()
390         return r[ 0 ] != 'SPAM'
391
392 #-----------------------------------------------------------------------------
393
394 def readUserRules( user ) :
395
396         import confparser
397         try :
398                 f = open( g_directoryRules + '/' + user + '.mf' )
399         except IOError :
400                 return
401         return confparser.parse( f.read() )
402
403 def ruleMatch( header , matchType , text ) :
404
405         if matchType == 'match' :
406                 try :
407                         return re.search( text , header , re.I ) != None
408                 except :
409                         logMessage( 'Error with regex `%s\' from %s\'s user configuration.' % ( text , g_user ) )
410                         return False
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
415         else :
416                 logMessage( 'Unknown match type `%s\' from %s\'s user configuration.' % ( matchType , g_user ) )
417         return False
418                         
419 def testRule( rule ) :
420
421         cmd = rule[ 0 ]
422         if cmd == 'and' :
423                 for subrule in rule[ 2 ] :
424                         if testRule( subrule ) == False :
425                                 break
426                 else :
427                         return True
428         elif cmd == 'or' :
429                 for subrule in rule[ 2 ] :
430                         if testRule( subrule ) == True :
431                                 return True
432         elif cmd == 'not' :
433                 for subrule in rule[ 2 ] :
434                         if testRule( subrule ) == True :
435                                 break
436                 else :
437                         return True
438         elif cmd == 'header' :
439                 if g_mail :
440                         args = rule[ 1 ]
441                         headerName , matchType , text = args
442                         header = g_mail[ headerName ] or ''
443                         header = header.replace( '\n' , ' ' )
444                         if ruleMatch( header , matchType , text ) :
445                                 return True
446         elif cmd == 'broken' :
447                 if not g_mail :
448                         return True
449         elif cmd == 'infected' :
450                 if g_mail :
451                         return not antivirusScan()
452         elif cmd == 'spam' :
453                 if g_mail :
454                         return not spamScan()
455         else :
456                 logMessage( 'Unknown rule name `%s\'.' % ( cmd , ) )
457         return False
458
459 #-----------------------------------------------------------------------------
460
461 def errorNameToErrorCode( code ) :
462
463         code = code.lower()
464         if code == 'nouser' :
465                 return EX_NOUSER
466         elif code == 'tempfail' :
467                 return EX_TEMPFAIL
468         elif code == 'dataerr' :
469                 return EX_DATAERR
470         else :
471                 try :
472                         return int( code )
473                 except :
474                         return 0
475
476 #
477 # Find the destination folder for user 'user' according to rules defined for
478 # him/her against the current mail.
479 #
480 # Return either:
481 #
482 #  ( False , mailBoxName | None )
483 #  ( True  , customErrorCode    )
484 #
485 def getDestFolder( user ) :
486
487         conf = readUserRules( user )
488
489         if not conf :
490
491                 logMessage( 'No rules defined for user `%s\'.' % user )
492                 return ( False , None )
493
494         if not conf[ 2 ] :
495
496                 logMessage( 'Empty rules set or syntax error encountered for user `%s\'.' % user )
497                 return ( False , None )
498
499         for item in conf[ 2 ] :
500
501                 action , args , subs = item[ : 3 ]
502
503                 if action not in [ 'folder' , 'reject' ] : continue
504
505                 for rule in subs :
506                         #
507                         # First rule that match is used.
508                         #
509                         if testRule( rule ) :
510                                 break
511                 else :
512                         continue
513
514                 if action == 'folder' :
515                         return ( False , args[ 0 ] )
516
517                 if action == 'reject' :
518                         try :
519                                 return ( True , errorNameToErrorCode( args[ 0 ] ) )
520                         except :
521                                 logMessage( 'Invalid reject code %r.' % args[ 0 ] )
522
523         return ( False , None )
524
525 #-----------------------------------------------------------------------------
526
527 #
528 # Dispatch the mail to the correct folder (deduced from rules.)
529 #
530 # Return either:
531 #
532 #  ( False , errorCode       )
533 #  ( True  , customErrorCode )
534 #
535 def dispatch() :
536
537         isCustomErrorCode , value = getDestFolder( g_user )
538
539         #
540         #
541         #
542         if isCustomErrorCode :
543                 return ( True , value )
544
545         #
546         # We got a folder name (or None for default folder.)
547         #
548         mbox = value
549         pseudoName = 'INBOX'
550         if mbox : pseudoName += '.' + mbox
551
552         #
553         # Try to deliver in the named folder
554         #
555         rc = deliverTo( g_user , mbox )
556         #
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.
560         #
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 )
565         #
566         # Check again.
567         #
568         # Here we also handle the case of EX_DATAERR not handled above.
569         #
570         if rc != EX_OK :
571                 logMessage( 'Error delivering to default folder of user `%s\'.' % ( g_user , ) )
572                 #
573                 # All errors code different from "data error" are translated to
574                 # "no user" error code.
575                 #
576                 if rc != EX_DATAERR :
577                         rc = EX_NOUSER
578
579         return ( False , rc )
580
581 #-----------------------------------------------------------------------------
582
583 def usage() :
584
585         print '''Usage: mail.filter [OPTIONS] [username]
586
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).
593
594 Report bugs to <fj@tuxee.net>.''' \
595         % ( g_pathLog , g_directoryRules )
596
597 def main() :
598
599         global g_user, g_mail, g_mailText, g_copyLogToStdout, g_pathLog, g_directoryRules , g_testMode
600
601         options , parameters = \
602                 getopt.getopt( sys.argv[ 1 : ] ,
603                         'hvtl:r:' ,
604                         ( 'help' , 'verbose' , 'test' , 'log=' , 'rules=' ) )
605
606         for option , argument in options :
607                 if option in [ '-h' , '--help' ] :
608                         usage()
609                         sys.exit( 0 )
610                 elif option in [ '-v' , '--verbose' ] :
611                         g_copyLogToStdout = True
612                 elif option in [ '-t' , '--test' ] :
613                         g_testMode = True
614                 elif option in [ '-l' , '--log' ] :
615                         g_pathLog = argument
616                 elif option in [ '-r' , '--rules' ] :
617                         g_directoryRules = argument
618
619         #
620         # At most one parameter expected.
621         #
622         if len( parameters ) > 1 :
623                 #
624                 # We just log a error message. We continue to proceed
625                 # to not lost the mail !
626                 #
627                 logMessage( 'Warning: More than one parameter on command line.' )
628
629         if parameters :
630                 g_user = parameters[ 0 ]
631
632         logMessage( 'Running mail.filter for user `%s\'.' % g_user )
633
634         #
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)
638         #
639         try :
640                 g_mailText = sys.stdin.read()
641         except :
642                 logMessage( getTraceBack() )
643                 sys.exit( 1 )
644
645         #
646         # Handling big mail.
647         #
648         if len( g_mailText ) > g_maxMailSize :
649                 logMessage( 'Message too big (%s bytes). Not filtering it.' % len( g_mailText ) )
650                 ok = False
651                 if g_user :
652                         ok = deliverTo( g_user )
653                         logMessage( 'Unable to deliver it to user `%s\'.' % g_user )
654                 if not ok :
655                         backup( g_user )
656                 sys.exit( 0 )
657
658         #
659         # Parsing the mail.
660         #
661         try :
662                 g_mail = email.message_from_string( g_mailText , strict = False )
663         except :
664                 logMessage( getTraceBack() )
665
666         #
667         # No user specified.
668         #
669         if not g_user :
670                 logMessage( 'No user specified.' )
671                 backup( g_user )
672                 sys.exit( 0 )
673         
674         #
675         # Return code default to "temporary failure".
676         #
677         isCustomErrorCode , rc = ( False , EX_TEMPFAIL )
678
679         #
680         # Dispatch the mail
681         #
682         try :
683                 isCustomErrorCode , rc = dispatch()
684         except :
685                 logMessage( getTraceBack() )
686
687         if not isCustomErrorCode :
688
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 )
692
693                 if rc != EX_OK :
694                         backup( g_user )
695                         if rc != EX_DATAERR :
696                                 rc = EX_NOUSER
697                         logMessage( 'Exit code is %d.' % rc )
698                 #
699                 # FIXME: !!!!!
700                 #
701                 rc = EX_OK
702
703         else :
704
705                 logMessage( 'Custom exit code is %d.' % rc )
706
707         sys.exit( rc )
708
709 if __name__ == '__main__' :
710         main()