1#------------------------------------------------------------------------------ 2# askmessage.py 3# 4# (C) 2001-2006 by Marco Paganini (paganini@paganini.net) 5# 6# This file is part of ASK - Active Spam Killer 7# 8# ASK 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# ASK 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 ASK; if not, write to the Free Software 20# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 21# 22# $Id: askmessage.py,v 1.106 2006/01/09 04:22:26 paganini Exp $ 23#------------------------------------------------------------------------------ 24 25import os 26import os.path 27import sys 28import string 29import rfc822 30import md5 31import re 32import time 33import tempfile 34import mimify 35import asklog 36import askconfig 37import askmail 38import asklock 39 40#------------------------------------------------------------------------------ 41 42class AskMessage: 43 """ 44 This is the main ASK class. 45 46 Attributes: 47 48 - tmpfile: Temporary file containing the mail message 49 - fh: File handle to the temporary file (do a seek(0) before using) 50 - binary_digest: MD5 digest in binary format 51 - ascii_digest: MD5 digest in ascii format 52 - conf_md5: MD5 digest found in the subject 53 54 - msg: rfc822 object 55 - md5sum: md5sum object 56 - log: LOG object 57 - config: CONFIG object 58 - mail: MAIL object 59 """ 60 61 #---------------------------------------------------------------------------------- 62 def __init__(self, config, log): 63 """ 64 Initializes the class instance. We need at least a valid CONFIG object 65 and a valid LOG object. 66 """ 67 68 self.fh = '' 69 self.tmpfile = '' 70 self.binary_digest = ''; 71 self.ascii_digest = ''; 72 self.list_match = ''; 73 self.set_conf_md5(''); 74 75 ## Initialize the LOG, CONFIG and MAIL objects 76 77 self.config = config 78 self.log = log 79 self.mail = askmail.AskMail(self.config, self.log) 80 81 self.mail.fullname = self.config.rc_myfullname; 82 83 #---------------------------------------------------------------------------------- 84 def __del__(self): 85 if self.fh: 86 self.fh.close(); 87 88 #self.log.write(10, " __del__(): Removing %s" % self.tmpfile) 89 os.unlink(self.tmpfile) 90 91 #---------------------------------------------------------------------------------- 92 def set_conf_md5(self, md5): 93 """ 94 Allows external callers to change the notion of the 'conf_md5', 95 or the md5sum retrieved from the subject line. This is useful 96 for instance, when you wish to process different (queued) files 97 using the methods in this class. 98 """ 99 100 self.conf_md5 = md5 101 102 #---------------------------------------------------------------------------------- 103 def read(self, input_fh): 104 """ 105 This function will read all data from the passed 'input_fh' into a 106 temporary file. This file will then be used for all sorts of operations 107 needed by this class. 108 """ 109 110 ## tmpfile must be on the same filesystem as ASK's private dir! 111 ## We'll use 'rename' later to change it to the definitive location 112 self.tmpfile = "%s/%s.%d.msg" % (self.config.rc_tmpdir, os.path.basename(tempfile.mktemp()), os.getpid()) 113 114 ## Create the temp filename and reopen as read/write 115 self.fh = open(self.tmpfile, "w") 116 self.fh.close() 117 self.fh = open(self.tmpfile, "r+b") 118 119 ## Create a new MD5 object 120 self.md5sum = md5.new() 121 122 ## Copy all the data from the passed file object to the new file 123 124 in_headers = 1 125 126 while 1: 127 buf = input_fh.readline() 128 if (buf == ''): 129 break 130 131 ## End of headers? 132 if len(string.strip(buf)) == 0: 133 in_headers = 0 134 135 ## Remove X-ASK-Action headers 136 if in_headers and re.search("^X-ASK-Action:", buf, re.IGNORECASE): 137 continue 138 139 self.md5sum.update(buf) 140 self.fh.write(buf) 141 142 ## Add the md5key to the hash and produce the ASCII digest 143 144 if (self.config.rc_md5_key != ''): 145 self.md5sum.update(self.config.rc_md5_key) 146 147 self.ascii_digest = '' 148 binary_digest = self.md5sum.digest() 149 150 for ch in range(0,len(binary_digest)): 151 self.ascii_digest = self.ascii_digest + "%02.2x" % ord(binary_digest[ch]) 152 153 ## Initialize the rfc822 object for our workfile 154 155 self.fh.seek(0) 156 self.msg = rfc822.Message(self.fh, 1) 157 158 #---------------------------------------------------------------------------------- 159 def get_real_sender(self): 160 """ 161 This is just like "get_sender", but the "X-Primary-Address" is not taken into 162 account. Useful for cases where you don't want to use this address (when sending 163 an email back, for instance). 164 """ 165 166 return(self.get_sender(ignore_header = "X-Primary-Address")) 167 168 #---------------------------------------------------------------------------------- 169 def get_sender(self, ignore_header=""): 170 """ 171 Returns a tuple in the format (sender_name, sender_email). Some processing is 172 done to treat X-Primary-Address/Resent-From/Reply-To/From. Headers in "ignore_header" 173 are not checked. 174 """ 175 176 headers = ["X-Primary-Address", "Resent-From", "Reply-To", "From"]; 177 178 ## Remove unwanted headers 179 def fn_remove(x, y = ignore_header): return(x != y) 180 headers = filter(fn_remove, headers) 181 182 sender_name = '' 183 sender_mail = '' 184 185 for hdr in headers: 186 (sender_name, sender_mail) = self.msg.getaddr(hdr) 187 188 if sender_name == None: sender_name = '' 189 if sender_mail == None: sender_mail = '' 190 191 ## Decode sender name and return if found 192 if sender_mail: 193 if sender_name: 194 sender_name = mimify.mime_decode_header(sender_name) 195 break 196 197 return(string.rstrip(sender_name), string.rstrip(sender_mail)) 198 199 #---------------------------------------------------------------------------------- 200 def get_recipients(self): 201 """ 202 Returns a list of tuples in the format (sender_name, sender_email). Note that 203 the list includes the contents of the TO, CC and BCC fields. 204 """ 205 206 to_list = self.msg.getaddrlist("To") ## To: 207 cc_list = self.msg.getaddrlist("Cc") ## Cc: 208 bcc_list = self.msg.getaddrlist("Bcc") ## Bcc: 209 210 recipients = [] 211 212 ## Master list of recipients 213 214 recipients.extend(to_list) 215 recipients.extend(cc_list) 216 recipients.extend(bcc_list) 217 218 return(recipients) 219 220 #---------------------------------------------------------------------------------- 221 def get_subject(self): 222 """ 223 Returns the subject of the message, or '' if none is defined. 224 """ 225 226 return(mimify.mime_decode_header(self.msg.getheader("Subject", ""))) 227 228 #---------------------------------------------------------------------------------- 229 def get_date(self): 230 """ 231 Returns a tuple containing the broken down date (from the "Date:" field) 232 The tuple contains (year, month, day, hour, minute, second, weekday, julianday, 233 dst_flag) and can be fed directly to time.mktime or time.strftime. 234 """ 235 236 ## Lots of messages have broken dates 237 try: 238 tuple = rfc822.parsedate(self.msg.getheader("Date", "")) 239 except: 240 tuple = '' 241 242 return(tuple) 243 244 #------------------------------------------------------------------------------ 245 def get_message_id(self): 246 """ 247 Returns the "Message-Id" field or '' if none is defined. 248 """ 249 250 return(self.msg.getheader("Message-Id", "")) 251 252 #------------------------------------------------------------------------------ 253 def __inlist(self, filenames, sender=None, recipients=None, subj=None): 254 """ 255 Checks if sender email, recipients or subject match one of the regexps 256 in the given filename array/tuple. If sender/recipient/subject are not 257 specified, the defaults for the current instance will be used. 258 """ 259 260 ## Fill in defaults 261 if sender == None: 262 sender = self.get_sender()[1] 263 264 if recipients == None: 265 recipients = self.get_recipients() 266 267 if subj == None: 268 subj = self.get_subject() 269 270 ## If the sender is one of our emails, we immediately return false. 271 ## This allows the use of "from @ourdomain.com" without having to 272 ## worry about matching from ouremail@ourdomain.com 273 274 if self.is_from_ourselves(sender): 275 self.log.write(10, " __inlist(): We are the sender (%s). No match performed." % sender) 276 self.list_match = "" 277 return 0 278 279 ## Test each filename in turn 280 281 for fname in filenames: 282 283 ## If the file does not exist, ignore 284 self.log.write(5, " __inlist(): filename=%s, sender=%s, dest=%s, subject=%s"% (fname, sender, recipients, subj)) 285 286 if (os.access(fname, os.R_OK) == 0): 287 self.log.write(5, " __inlist(): %s does not exist (or is unreadable). Ignoring..." % fname) 288 continue 289 290 fh = open(fname, "r") 291 292 while 1: 293 294 buf = fh.readline() 295 if (buf == ''): 296 break 297 298 buf = string.lower(string.rstrip(buf)) 299 300 ## Ignore comments and blank lines 301 if (buf != '' and buf[0] != '#'): 302 ## self.log.write(20," __inlist(): regexp=[%s]" % buf) 303 304 ## "from re" matches sender 305 ## "to re" matches destination (to, bcc, cc) 306 ## "subject re" matches subject 307 ## "header re" matches a full regexp in the headers 308 ## "re" matches sender (previous behavior) 309 310 strlist = [] 311 312 ## from 313 res = re.match("from\s*(.*)", buf, re.IGNORECASE) 314 if res: 315 ## No blank 'from' 316 if not res.group(1): 317 continue 318 319 regex = self.__regex_adjust(res.group(1)) 320 strlist.append(sender) 321 else: 322 ## to 323 res = re.match("to\s*(.*)", buf, re.IGNORECASE) 324 if res: 325 regex = self.__regex_adjust(res.group(1)) 326 ## Append all recipients 327 for (recipient_name, recipient_email) in recipients: 328 strlist.append(recipient_email) 329 else: 330 ## subject 331 res = re.match("subject\s*(.*)", buf, re.IGNORECASE) 332 333 if res: 334 regex = res.group(1) 335 strlist.append(subj) 336 else: 337 ## header 338 res = re.match("header\s*(.*)", buf, re.IGNORECASE) 339 if res: 340 regex = res.group(1) 341 strlist.extend(self.msg.headers) 342 else: 343 regex = self.__regex_adjust(buf) 344 strlist.append(sender) 345 346 ## Match... 347 348 for str in strlist: 349 try: 350 if (regex and re.search(regex, str, re.IGNORECASE)): 351 self.log.write(5, " __inlist(): found with re=%s" % buf) 352 self.list_match = buf 353 fh.close() 354 return 1 355 except: 356 self.log.write(1," __inlist(): WARNING: Invalid regular expression: \"%s\". Ignored." % regex) 357 else: 358 self.log.write(20, " __inlist(): ignoring line [%s]" % buf) 359 360 fh.close() 361 362 self.list_match = "" 363 return 0 364 365 #------------------------------------------------------------------------------ 366 def __regex_adjust(self, str): 367 """ 368 Returns the "adjusted" regular expression to be used in matching 369 email items. If the regular expression itself matches xxx@xxx, we 370 assume a full address match is desired (we try to match using ^re$ or 371 else r@boo.org would also match spammer@boo.org). Otherwise, just use 372 a regular regexp to match anywhere. 373 """ 374 375 if re.search(".+@.+", str): 376 return("^" + str + "$") 377 else: 378 return(str) 379 380 #------------------------------------------------------------------------------ 381 def is_from_ourselves(self, email = None): 382 """ 383 Returns true if the sender is one of our emails. Use 'email' instead of 384 the current sender if specified. 385 """ 386 387 if not email: 388 email = string.lower(self.get_sender()[1]) 389 390 if (email in map(string.lower, self.config.rc_mymails)): 391 return 1 392 else: 393 return 0 394 395 #------------------------------------------------------------------------------ 396 def is_in_whitelist(self): 397 """ 398 Returns true if this message is in the whitelist. False otherwise 399 """ 400 if self.__inlist(self.config.rc_whitelist): 401 return 1 402 else: 403 return 0 404 405 #------------------------------------------------------------------------------ 406 def is_in_ignorelist(self): 407 """ 408 Returns true if this message is in the ignorelist. False otherwise 409 """ 410 if self.__inlist(self.config.rc_ignorelist): 411 return 1 412 else: 413 return 0 414 415 #------------------------------------------------------------------------------ 416 def has_our_key(self): 417 """ 418 Returns true if this message contains our mailkey. False otherwise 419 """ 420 if self.__contains_string(self.config.rc_mailkey): 421 return 1 422 else: 423 return 0 424 425 #------------------------------------------------------------------------------ 426 def is_from_mailerdaemon(self): 427 """ 428 Returns true if this message comes from MAILER-DAEMON. False otherwise 429 """ 430 431 if re.match("mailer-daemon|postmaster@|<>", self.get_sender(ignore_header = "Reply-To")[1], re.IGNORECASE): 432 return 1 433 else: 434 return 0 435 436 #------------------------------------------------------------------------------ 437 def sent_by_ask(self): 438 """ 439 Looks for the X-AskVersion rfc822 header. If one is found, we assume ASK 440 sent the message and we return true. Otherwise, we return false. 441 """ 442 443 if self.__contains_string("^X-AskVersion:"): 444 return 1 445 else: 446 return 0 447 448 #------------------------------------------------------------------------------ 449 def valid_smtp_sender(self): 450 """ 451 Uses mail.smtp_validate to check the sender's address. Return true if 452 probably valid, 0 if definitely invalid. 453 """ 454 455 ret = self.mail.smtp_validate(email = self.get_sender()[1], 456 envelope_from = self.get_matching_recipient()) 457 458 self.log.write(5, " valid_smtp_sender: SMTP authentication returned %d" % ret) 459 460 ## Returns possibly valid in case of errors. 461 462 if ret != 0: 463 return 1 464 else: 465 return 0 466 467 #------------------------------------------------------------------------------ 468 def deliver_mail(self, x_ask_info="Delivering Mail"): 469 """ 470 Delivers the current mail to the mailbox contained in self.config.rc_mymailbox 471 or to stdout if in filter or procmail mode. 472 """ 473 474 ## Deliver to stdout if using procmail/filter 475 if self.config.procmail_mode or self.config.filter_mode: 476 mailbox = "-" 477 else: 478 mailbox = self.config.rc_mymailbox 479 480 self.log.write(1, " deliver_mail(): Delivering mail") 481 self.mail.deliver_mail_file(mailbox, 482 self.tmpfile, 483 x_ask_info = x_ask_info, 484 custom_headers = [ "X-ASK-Auth: " + self.generate_auth() ]) 485 486 return 0 487 488 #------------------------------------------------------------------------------ 489 def junk_mail(self, x_ask_info="Junk"): 490 """ 491 Queues the current mail with a status of "Junk" or delivers to 492 rc_junkmailbox if is defined in the config file. 493 """ 494 495 if self.config.rc_junkmailbox: 496 self.log.write(1," junk_mail(): Saving message to %s" % self.config.rc_junkmailbox) 497 self.mail.deliver_mail_file(self.config.rc_junkmailbox, 498 self.tmpfile, 499 x_ask_info = x_ask_info, 500 custom_headers = [ "X-ASK-Auth: " + self.generate_auth() ]) 501 else: 502 self.log.write(1, " junk_mail(): Queueing Junk Message") 503 self.queue_mail(x_ask_info = x_ask_info) 504 505 return 0 506 507 #------------------------------------------------------------------------------ 508 def bulk_mail(self, x_ask_info="Bulk"): 509 """ 510 Queues the current mail with a status of "Bulk" or delivers to 511 rc_bulkmailbox if is defined in the config file. 512 """ 513 514 if self.config.rc_bulkmailbox: 515 self.log.write(1," bulk_mail(): Saving message to %s" % self.config.rc_bulkmailbox) 516 self.mail.deliver_mail_file(self.config.rc_bulkmailbox, 517 self.tmpfile, 518 x_ask_info = x_ask_info, 519 custom_headers = [ "X-ASK-Auth: " + self.generate_auth() ]) 520 else: 521 self.log.write(1, " bulk_mail(): Queueing Bulk Message") 522 self.queue_mail(x_ask_info = x_ask_info) 523 524 return 0 525 526 #------------------------------------------------------------------------------ 527 def queue_mail(self, x_ask_info="Message Queued"): 528 """ 529 Queues the current message. The queue directory and calculated MD5 530 are used to generate the filename. Note that the MD5 reflect the original 531 contents of the message (not the queued one, as headers are added as needed). 532 533 If the message queue directory (rc_askmsg) ends in "/", we assume the 534 queue will be in mqueue format. 535 """ 536 537 self.log.write(5, " queue_mail(): x_ask_info = %s" % x_ask_info) 538 self.log.write(5, " queue_mail(): The MD5 checksum for %s is %s" % (self.tmpfile, self.ascii_digest)) 539 540 ## If the msgdir ends in "/" we assume a Maildir style queue. If 541 ## not, we have a regular queue. 542 543 if self.config.queue_is_maildir: 544 self.log.write(5, " queue_mail(): Maildir format. Queue dir = %s" % self.config.rc_msgdir) 545 self.mail.deliver_mail_file(self.config.rc_msgdir, 546 self.tmpfile, 547 x_ask_info = x_ask_info, 548 uniq = self.ascii_digest, 549 custom_headers = [ "X-ASK-Auth: " + self.generate_auth() ]) 550 else: 551 queueFileName = self.queue_file() 552 self.log.write(5, " queue_mail(): Mailbox format. Queue file = %s" % queueFileName) 553 self.mail.deliver_mail_file(queueFileName, 554 self.tmpfile, 555 x_ask_info, 556 custom_headers = [ "X-ASK-Auth: " + self.generate_auth() ]) 557 558 return 0 559 560 #------------------------------------------------------------------------------ 561 def send_confirmation(self): 562 """ 563 Sends a confirmation message back to the sender. 564 """ 565 566 queueFileName = self.queue_file() 567 568 self.mail.mailfrom = self.get_matching_recipient() 569 570 self.log.write(1, " send_confirmation(): Sending confirmation from %s to %s" % (self.mail.mailfrom, self.get_real_sender()[1])) 571 572 ## Add Precedence: bulk and (if appropriate) "In-Reply-To" 573 574 header_list = [ "X-ASK-Auth: %s" % self.generate_auth(), "Precedence: bulk", "Content-Type: text/plain; charset=\"iso-8859-1\"", "Content-Transfer-Encoding: 8bit" ] 575 576 msgid = self.get_message_id() 577 578 if msgid: 579 header_list.append("In-Reply-To: %s" % msgid) 580 581 if (self.__check_confirm_list(self.get_real_sender()[1])): 582 self.mail.send_mail(self.get_real_sender()[1], 583 "Please confirm (conf#%s)" % self.ascii_digest, 584 self.config.rc_confirm_filenames, 585 [queueFileName], 586 custom_headers = header_list, 587 max_attach_lines = self.config.rc_max_attach_lines) 588 589 self.log.write(1, " send_confirmation(): Confirmation sent to %s..." % self.get_real_sender()[1]) 590 else: 591 self.log.write(1, " send_confirmation(): too many confirmations already sent to %s..." % self.get_real_sender()[1]) 592 593 #------------------------------------------------------------------------------ 594 595 def __check_confirm_list(self, address): 596 """ 597 Adds address to the list of confirmation targets. 598 Returns true if this confirmation should be sent. 599 Returns false otherwise. 600 """ 601 602 name = address + "\n" 603 604 queue_file_name = "%s/.ask-loop-control.dat" % self.config.rc_askdir 605 606 ## Confirmation list file will be created if it does not exist 607 if not os.path.exists(queue_file_name): 608 queue_file_handle = open(queue_file_name, "w") 609 queue_file_handle.close() 610 611 ## Lock 612 queue_file_handle = asklock.AskLock() 613 lockf = "" 614 615 if self.config.rc_lockfile != "": 616 lockf = self.config.rc_lockfile + "." + os.path.basename(queue_file_name) 617 else: 618 lockf = queue_file_name 619 620 queue_file_handle.open(queue_file_name, "r+", lockf) 621 622 ## Read all stuff from .ask-loop-control.dat 623 624 if os.path.exists(queue_file_name): 625 queue_file_handle.seek(0) 626 confirm_list = queue_file_handle.readlines() 627 else: 628 confirm_list = [] 629 630 confirm_list.append(name) 631 632 ## Trim list 633 if len(confirm_list) > self.config.rc_max_confirmation_list: 634 del confirm_list[0:self.config.rc_max_confirmation_list - self.config.rc_min_confirmation_list] 635 636 ## Rewrite trimmed list and release lock 637 638 queue_file_handle.seek(0) 639 queue_file_handle.truncate(0) 640 queue_file_handle.writelines(confirm_list) 641 642 queue_file_handle.close() 643 644 ## More than max_confirmations? 645 if (confirm_list.count(name) > self.config.rc_max_confirmations): 646 return 0 647 648 return 1 649 #------------------------------------------------------------------------------ 650 651 def queue_file(self, md5str=""): 652 """ 653 Returns the full path to the queue file of the current message 654 (self.ascii_digest). The 'md5' parameter can be used to override 655 the MD5 checksum. If the queue is in maildir format, the queue 656 directory will be opened and the first file containing the MD5 657 will be returned. 658 """ 659 660 if not md5str: 661 md5str = self.ascii_digest 662 663 if self.config.queue_is_maildir: 664 665 ## Original Path if Maildir 666 maildir = string.replace(self.config.rc_msgdir, "/cur", "") 667 maildir = string.replace(maildir, "/new", "") 668 maildir = string.replace(maildir, "/tmp", "") 669 670 ## /cur 671 file_list = filter(lambda x,md5str_in = md5str: (string.find(x, md5str_in) != -1), os.listdir(maildir + "cur")) 672 673 if len(file_list) != 0: 674 return(os.path.join(maildir + "cur", file_list[0])) 675 676 ## /new 677 file_list = filter(lambda x,md5str_in = md5str: (string.find(x, md5str_in) != -1), os.listdir(maildir + "new")) 678 679 if len(file_list) != 0: 680 return(os.path.join(maildir + "new", file_list[0])) 681 682 ## Nothing found, return blank 683 return "" 684 685 else: 686 return "%s/ask.msg.%s" % (self.config.rc_msgdir, md5str) 687 688 #------------------------------------------------------------------------------ 689 690 def discard_mail(self, x_ask_info="Discard", mailbox = '', via_smtp = 0): 691 """ 692 This method will deliver the current message to stdout and append a 693 'X-ASK-Action: Discard' header to it. This will only happen if we're 694 operating in "filter" mode. 695 """ 696 697 if not self.config.filter_mode: 698 return 699 700 self.log.write(10, " discard_mail: Sending email to stdout with X-ASK-Action: Discard") 701 702 self.mail.deliver_mail_file("-", 703 self.tmpfile, 704 x_ask_info = x_ask_info, 705 custom_headers = [ "X-ASK-Action: Discard", 706 "X-ASK-Auth: " + self.generate_auth() ]) 707 708 return 0 709 710 #------------------------------------------------------------------------------ 711 712 def dequeue_mail(self, x_ask_info="Message dequeued", mailbox = '', via_smtp = 0): 713 """ 714 Dequeues (delivers) mail in the queue directory to the current user. 715 The queued message will be directly appended to the mailbox. The 'mailbox' 716 parameter can be specified to force delivery to something different than 717 the default config.rc_mymailbox. 718 """ 719 720 if not mailbox: 721 ## Deliver to stdout if using procmail/filter 722 if self.config.procmail_mode or self.config.filter_mode: 723 mailbox = "-" 724 else: 725 mailbox = self.config.rc_mymailbox 726 727 queueFileName = self.queue_file(self.conf_md5) 728 self.log.write(1, " dequeue_mail(): Delivering mail from %s to mailbox %s" % (queueFileName, mailbox)) 729 730 if via_smtp: 731 self.mail.mailfrom = self.config.rc_mymails[0] 732 733 self.mail.send_mail_file(self.config.rc_mymails[0], 734 queueFileName, 735 x_ask_info = x_ask_info, 736 custom_headers = [ "X-ASK-Auth: " + self.generate_auth() ]) 737 else: 738 self.mail.deliver_mail_file(mailbox, 739 queueFileName, 740 x_ask_info = x_ask_info, 741 custom_headers = [ "X-ASK-Auth: " + self.generate_auth() ]) 742 743 os.unlink(queueFileName) 744 745 return 0 746 747 #------------------------------------------------------------------------------ 748 749 def delete_mail(self, x_ask_info="No further info"): 750 """ 751 Deletes queued file in the queue directory. 752 """ 753 754 queueFileName = self.queue_file(self.conf_md5) 755 os.unlink(queueFileName) 756 757 self.log.write(1, " delete_mail(): Queued file %s deleted" % queueFileName) 758 759 return 0 760 761 #------------------------------------------------------------------------------ 762 def is_queued(self): 763 """ 764 Checks if a queued message exists matching the current MD5 signature. 765 """ 766 767 queueFileName = self.queue_file() 768 769 if (os.access(queueFileName, os.F_OK) == 1): 770 self.log.write(1, " is_queued(): File %s found. Message is queued" % queueFileName) 771 return 1 772 else: 773 self.log.write(1, " is_queued(): File %s not found. Message is not queued" % queueFileName) 774 return 0 775 776 #------------------------------------------------------------------------------ 777 def confirmation_msg_queued(self): 778 """ 779 Returns true if the current message is a confirmation message AND 780 a queued message exists. False otherwise. 781 """ 782 783 queueFileName = self.queue_file(self.conf_md5) 784 785 if (os.access(queueFileName, os.F_OK) == 1): 786 self.log.write(1, " confirmation_msg_queued(): File %s found. Message is queued" % queueFileName) 787 return 1 788 else: 789 self.log.write(1, " confirmation_msg_queued(): File %s not found. Message is not queued" % queueFileName) 790 return 0 791 792 #------------------------------------------------------------------------------ 793 def is_confirmation_return(self): 794 """ 795 Checks whether the message subject is a confirmation. If so, self.conf_md5 796 will be set to the MD5 hash found in the subject true will be returned. 797 """ 798 799 subject = self.get_subject() 800 801 self.log.write(10, " is_confirmation_return(): Subject=" + subject) 802 803 res = re.search("\(conf[#:]([a-f0-9]{32})\)", subject, re.IGNORECASE) 804 805 if (res == None): 806 self.log.write(1, " is_confirmation_return(): Didn't find conf#MD5 tag on subject") 807 self.conf_md5 = '' 808 return 0 809 else: 810 self.log.write(1, " is_confirmation_return(): Found conf$md5 tag, MD5=%s" % res.group(1)) 811 self.conf_md5 = res.group(1) 812 return 1 813 814 #------------------------------------------------------------------------------ 815 def add_queued_msg_to_whitelist(self): 816 """ 817 Adds the sender in the message pointed to by 'conf_md5' to the whitelist. 818 """ 819 820 queueFileName = self.queue_file(self.conf_md5) 821 822 ## Create a new AskMessage instance with the queued file 823 aMessage = AskMessage(self.config, self.log) 824 queueFilehandle = open(queueFileName, "r") 825 826 aMessage.read(queueFilehandle) 827 queueFilehandle.close() 828 829 self.log.write(1, " add_queued_msg_to_whitelist(): Adding message %s to whitelist" % queueFileName) 830 aMessage.add_to_whitelist() 831 832 #------------------------------------------------------------------------------ 833 def add_to_whitelist(self, regex = None): 834 """ 835 Adds the current sender or the optional 'regex' to the whitelist. 836 """ 837 838 if not regex: 839 regex = self.get_sender()[1] 840 841 self.__add_to_list(self.config.rc_whitelist, regex) 842 843 return 0 844 845 #------------------------------------------------------------------------------ 846 def add_to_ignorelist(self, regex = None): 847 """ 848 Adds the current sender or the optional 'regex' to the ignorelist. 849 """ 850 851 if not regex: 852 regex = self.get_sender()[1] 853 854 self.__add_to_list(self.config.rc_ignorelist, regex) 855 856 return 0 857 858 #------------------------------------------------------------------------------ 859 def remove_from_whitelist(self, email): 860 """ 861 Remove the passed email from the whitelist. 862 """ 863 864 self.__remove_from_list(self.config.rc_whitelist, email) 865 return 0 866 867 #------------------------------------------------------------------------------ 868 def remove_from_ignorelist(self, email): 869 """ 870 Remove the passed email from the ignorelist. 871 """ 872 873 self.__remove_from_list(self.config.rc_ignorelist, email) 874 return 0 875 876 #----------------------------------------------------------------------------- 877 def __add_to_list(self, filenames, email=None): 878 """ 879 Adds the specified 'email' to the first filename in the array 'filenames'. 880 Defaults to using the current sender if none is specified. 881 """ 882 883 ## Defaults 884 if email == None: 885 email = self.get_sender()[1] 886 887 ## Make sure it's not one of our own emails... 888 if self.is_from_ourselves(email): 889 self.log.write(1, " __add_to_list(): \"%s\" is listed as one of our emails. It will not be added to the list (%s)" % (email, filenames[0])) 890 return -1 891 892 ## Do not add if it's already there... (Compare sender only) 893 if self.__inlist(filenames, 894 sender = email, 895 recipients = [("*IGNORE*","*IGNORE*")], 896 subj = "*IGNORE*"): 897 self.log.write(1, " __add_to_list(): \"%s\" is already present in \"%s\". It will not be re-added" % (email, filenames[0])) 898 return -1 899 900 self.log.write(10, " __add_to_list(): filename=%s, email=%s" % (filenames[0], email)) 901 902 lck = asklock.AskLock() 903 904 lockf = "" 905 if self.config.rc_lockfile != "": 906 lockf = self.config.rc_lockfile + "." + os.path.basename(filenames[0]) 907 908 lck.open(filenames[0], "a", lockf) 909 910 ## We suppose the file is unlocked when we get here... 911 lck.write("from " + self.__escape_regex(email) + "\n") 912 lck.close() 913 914 self.log.write(1, " __add_to_list(): \"%s\" added to %s" % (email, filenames[0])) 915 916 return 0 917 918 #----------------------------------------------------------------------------- 919 def __remove_from_list(self, filenames, email=None): 920 """ 921 Removes the specified 'email' from the first filename in the array 922 'filenames'. Note that entries that would allow this list to match are 923 NOT removed, but instead the email passed is used as the regexp in the 924 match. This is so to avoid removing more generic regexps added by 925 the user. 926 927 Note that unlike "add_to_list", email is a mandatory parameter 928 (for security reasons). 929 930 """ 931 932 ## Make sure it's not one of our own emails... 933 if self.is_from_ourselves(email): 934 self.log.write(1, " __remove_from_list: \"%s\" is listed as one of our emails. It will not be removed from the list (%s)" % (email, filenames[0])) 935 return -1 936 937 self.log.write(10, " __remove_from_list: filename=%s, email=%s" % (filenames[0], email)) 938 939 lck = asklock.AskLock() 940 941 lockf = "" 942 if self.config.rc_lockfile != "": 943 lockf = self.config.rc_lockfile + "." + os.path.basename(filenames[0]) 944 945 lck.open(filenames[0], "r+", lockf) 946 947 ## We read the whole file in memory and re-write without 948 ## the passed email. Note that we expect the file to fit in memory. 949 950 oldlist = lck.readlines() 951 newlist = [] 952 953 ## email must be alone on a line 954 email = "^" + email + "$" 955 956 for regex in listarray: 957 if not re.match(regex, email, re.IGNORECASE): 958 newlist.append(regex) 959 960 lck.seek(0) 961 lck.writelines(newlist) 962 lck.close() 963 964 return 0 965 966 #------------------------------------------------------------------------------ 967 def invalid_sender(self): 968 """ 969 Performs some basic checking on the sender's email and return true if 970 it seems to be invalid. 971 """ 972 973 sender_email = self.get_sender()[1] 974 975 if (sender_email == '' or string.find(sender_email, '@') == -1 or string.find(sender_email,'|') != -1): 976 self.log.write(1, " invalid_sender(): Malformed 'From:' line (%s). " % sender_email) 977 return 1 978 else: 979 return 0 980 981 #------------------------------------------------------------------------------ 982 def is_mailing_list_message(self): 983 """ 984 We try to identify (using some common headers) whether this message comes 985 from a mailing list or not. If it does, we return 1. Otherwise, return 0 986 """ 987 988 if (self.msg.getheader("Mailing-List",'') != '' or 989 self.msg.getheader("List-Id",'') != '' or 990 self.msg.getheader("List-Help",'') != '' or 991 self.msg.getheader("List-Post",'') != '' or 992 self.msg.getheader("Return-Path",'') == '<>' or 993 re.match("list|bulk", self.msg.getheader("Precedence",''), re.IGNORECASE) or 994 re.match(".*(majordomo|listserv|listproc|netserv|owner|bounce|mmgr|autoanswer|request|noreply|nobody).*@", self.msg.getheader("From",''), re.IGNORECASE)): 995 return 1 996 else: 997 return 0 998 999 #------------------------------------------------------------------------------ 1000 def __escape_regex(self, str): 1001 """ 1002 Escapes dots and other meaningful characters in the regular expression. 1003 Returns the "escaped" string. This routine is pretty dumb meaning that 1004 it does not know how to handle an already escaped string. Use only in 1005 "raw", unescaped strings. 1006 """ 1007 1008 evil_chars = "\\.^$*+|{}[]"; 1009 1010 for var in evil_chars: 1011 str = string.replace(str, var, "\\"+var) 1012 1013 return(str) 1014 1015 #------------------------------------------------------------------------------ 1016 def generate_auth(self): 1017 """ 1018 Generates an authentication string containing the current time and the 1019 MD5 sum of the current time + our rc5_key. This is normally sent out 1020 in every email generated by ASK in the X-ASK-Auth: email header. 1021 """ 1022 1023 md5sum = md5.new() 1024 1025 ## number of seconds since epoch as a string 1026 numsecs = "%d" % int(time.time()) 1027 1028 md5sum.update(numsecs) ## Seconds and... 1029 md5sum.update(self.config.rc_md5_key) ## md5 key... 1030 1031 ascii_md5 = '' 1032 1033 for ch in range(0,len(md5sum.digest())): 1034 ascii_md5 = ascii_md5 + "%02.2x" % ord(md5sum.digest()[ch]) 1035 1036 self.log.write(5, " generate_auth(): Authentication = %s-%s" % (numsecs, ascii_md5)) 1037 1038 return "%s-%s" % (numsecs, ascii_md5) 1039 1040 #------------------------------------------------------------------------------ 1041 def __get_auth_tokens(self, body = 0): 1042 """ 1043 Reads and parse the authorization string from the X-ASK-Auth SMTP header or 1044 from the email body, if the 'body' parameter is set to 1. Normally, a tuple 1045 in the format (int(numsecs),md5sum) is returned. If the header does not exist 1046 or cannot be parsed, ("","") is returned. 1047 """ 1048 1049 ## Auth string may come from the body or from the headers 1050 1051 authstring = None 1052 1053 if body: 1054 self.msg.rewindbody() 1055 1056 ## Skip headers (until first blank line) 1057 while string.strip(self.msg.fp.readline()): 1058 pass 1059 1060 ## Search for Auth Token 1061 while 1: 1062 buf = self.msg.fp.readline() 1063 if buf == '': 1064 break 1065 1066 res = re.search("X-ASK-Auth: ([0-9]*)-([0-9a-f]*)", buf, re.IGNORECASE) 1067 1068 if res: 1069 authstring = buf 1070 break 1071 else: 1072 authstring = self.msg.getheader("X-ASK-Auth", "") 1073 1074 if not authstring: 1075 self.log.write(1, " __get_auth_tokens(): No X-ASK-Auth SMTP header found") 1076 return ("","") 1077 1078 self.log.write(5, " __get_auth_tokens(): Authentication string = [%s]" % authstring) 1079 1080 ## Parse age and MD5 1081 res = re.search("([0-9]*)-([0-9a-f]*)", authstring, re.IGNORECASE) 1082 1083 if not res: 1084 self.log.write(1, " __get_auth_tokens(): Cannot parse X-ASK-Auth SMTP header") 1085 original_time = 0 1086 original_md5 = None 1087 else: 1088 original_time = int(res.group(1)) 1089 original_md5 = res.group(2) 1090 1091 return(int(original_time), original_md5) 1092 1093 #------------------------------------------------------------------------------ 1094 def validate_auth_md5(self, body = 0): 1095 """ 1096 Validates the MD5sum part of the X-ASK-Auth SMTP header. 1097 Returns 1 if the authentication is valid, zero otherwise. If body = 1, 1098 the authentication token will be performed in the email's body as 1099 opposed to the headers. 1100 """ 1101 1102 (original_time, original_ascii_md5) = self.__get_auth_tokens(body = body) 1103 1104 if not original_ascii_md5: 1105 self.log.write(1, " validate_auth_md5(): Cannot read authorization tokens. Authentication Failed.") 1106 return 0 1107 1108 ## Check the md5sum (original_time + rc_md5_key) 1109 md5sum = md5.new() 1110 md5sum.update("%s" % original_time) ## Seconds and... 1111 md5sum.update(self.config.rc_md5_key) ## md5 key... 1112 1113 ascii_md5 = '' 1114 1115 for ch in range(0,len(md5sum.digest())): 1116 ascii_md5 = ascii_md5 + "%02.2x" % ord(md5sum.digest()[ch]) 1117 1118 ## Compare 1119 if ascii_md5 == original_ascii_md5: 1120 self.log.write(1, " validate_auth_md5(): Authentication succeeded") 1121 return 1 1122 else: 1123 self.log.write(1, " validate_auth_md5(): MD5 tokens do not match (should be %s). Authentication failed" % ascii_md5) 1124 return 0 1125 1126 #------------------------------------------------------------------------------ 1127 def validate_auth_time(self, maxdays, body = 0): 1128 """ 1129 Validates the time part of the X-ASK-Auth SMTP header. 1130 Returns 1 if the authentication is valid (newer than maxdays), 0 if not. 1131 if body == 1, the authentication will be performed in the email's 1132 body, as opposed to the headers. 1133 """ 1134 1135 (original_time, original_ascii_md5) = self.__get_auth_tokens(body = body) 1136 1137 if not original_ascii_md5: 1138 self.log.write(1, " validate_auth_time(): Cannot read authorization tokens. Authentication Failed.") 1139 return 0 1140 1141 ## Check if original_time is older than 'maxdays' days. 1142 1143 current_time = int(time.time()) 1144 1145 if (original_time >= (current_time - (maxdays * 86400))): 1146 self.log.write(1, " validate_auth_time(): Time Authentication succeeded") 1147 return 1 1148 else: 1149 self.log.write(1, " validate_auth_time(): Auth time is older than %d days. Authentication failed" % maxdays) 1150 return 0 1151 1152 #------------------------------------------------------------------------------ 1153 def summary(self, maxlen = 150): 1154 """ 1155 Returns a string containing a preview of the current message. HTML 1156 code will be stripped from the input. At most 'maxlen' bytes will 1157 be copied from the original mail. 1158 """ 1159 1160 self.msg.rewindbody() 1161 1162 content_boundary = '' 1163 1164 ## Skip to "boundary" if Content-type == multipart 1165 content_type = self.msg.getheader("Content-Type", "") 1166 1167 res = re.search("boundary.*=.*\"(.*)\"", content_type, re.IGNORECASE) 1168 if not res: 1169 res = re.search("boundary.*=[ ]*(.*)[, ]*", content_type, re.IGNORECASE) 1170 1171 if res: 1172 content_boundary = res.group(1) 1173 self.log.write(10, " summary: content_boundary = %s" % content_boundary) 1174 1175 # Skip to Content-Boundary, if any 1176 1177 found = 0 1178 while 1: 1179 buf = self.msg.fp.readline() 1180 1181 if buf == '': 1182 break 1183 1184 if string.find(buf, content_boundary) != -1: 1185 found = 1 1186 break 1187 1188 if found: 1189 ## Skip until first blank line or EOF 1190 while 1: 1191 buf = string.strip(self.msg.fp.readline()) 1192 if not buf: 1193 break 1194 else: 1195 ## There is a content-type/boundary but we couldn't 1196 ## find one. Just rewind the message body to the start of it. 1197 self.msg.rewindbody() 1198 1199 ## We read the next 100 lines skipping "content-boundary" (if any) 1200 ## and stopping at the end-of-file if found first. 1201 1202 result = '' 1203 buf = '' 1204 lines = 100 1205 1206 while lines > 0: 1207 buf = self.msg.fp.readline() 1208 if buf == '' or (content_boundary != '' and string.find(buf, content_boundary) != -1): 1209 break 1210 1211 result = result + string.strip(buf) + " " 1212 1213 result = self.strip_html(result) 1214 result = string.rstrip(result) 1215 result = string.lstrip(result) 1216 1217 if len(result) > maxlen: 1218 result = result[0:maxlen] + "(...)" 1219 1220 return result 1221 1222 #------------------------------------------------------------------------------ 1223 def __contains_string(self, regexp): 1224 "Returns line containing string if regexp matches any line in the current message" 1225 1226 self.fh.seek(0) 1227 1228 while 1: 1229 1230 buf = self.fh.readline() 1231 1232 if (buf == ''): 1233 break 1234 1235 try: 1236 if (re.search(regexp, buf, re.IGNORECASE)): 1237 return buf 1238 except: 1239 self.log.write(1," __contains_string(): WARNING: Invalid regular expression: \"%s\". Ignored." % regexp) 1240 1241 return "" 1242 1243 #------------------------------------------------------------------------------ 1244 def __save_to(self, dest): 1245 """ 1246 Saves the current message text into file 'dest' 1247 """ 1248 1249 self.log.write(5, " __save_to(): Copying to %s" % dest) 1250 1251 self.fh.seek(0) 1252 fh_output = open(dest, "w") 1253 1254 while 1: 1255 buf = self.fh.readline() 1256 1257 if buf == '': 1258 break 1259 1260 fh_output.write(buf) 1261 1262 fh_output.close() 1263 1264 #------------------------------------------------------------------------------ 1265 def match_recipient(self): 1266 """ 1267 This function will try to match the recipient of the message in the list 1268 of recipients contained in the rc_mymails array. If one is found, it is 1269 returned. Otherwise, it returns None. 1270 """ 1271 1272 for (recipient_name, recipient_mail) in self.get_recipients(): 1273 for our_address in self.config.rc_mymails: 1274 if recipient_mail == our_address: 1275 self.log.write(1, " match_recipient(): Found a match with %s" % our_address) 1276 return our_address 1277 1278 self.log.write(1, " match_recipient(): No Match found.") 1279 1280 return None 1281 1282 #------------------------------------------------------------------------------ 1283 def get_matching_recipient(self): 1284 """ 1285 This function will call "match_recipient" to determine whether the recipient 1286 of the current email is in our list of recipients. If so, the corresponding 1287 recipient will be returned. If not, an appropriate default will be chosen 1288 (usually the first email in the list rc_mymails). 1289 """ 1290 1291 email = self.match_recipient() 1292 1293 if not email: 1294 email = self.config.rc_mymails[0] 1295 1296 self.log.write(1, " get_matching_recipient(): Returning %s" % email) 1297 1298 return email 1299 1300 #------------------------------------------------------------------------------ 1301 1302 def strip_html(self, str): 1303 """ 1304 Strips all HTML from the input string and returns the stripped string. 1305 """ 1306 1307 import sgmllib, string 1308 1309 class sgmlstrip(sgmllib.SGMLParser): 1310 def __init__(self): 1311 self.data = [] 1312 sgmllib.SGMLParser.__init__(self) 1313 1314 def unknown_starttag(self, tag, attrib): 1315 self.data.append(" ") 1316 1317 def unknown_endtag(self, tag): 1318 self.data.append(" ") 1319 1320 def handle_data(self, data): 1321 self.data.append(data) 1322 1323 def gettext(self): 1324 text = string.join(self.data, "") 1325 return string.join(string.split(text)) # normalize whitespace 1326 1327 try: 1328 s = sgmlstrip() 1329 s.feed(str) 1330 s.close() 1331 except sgmllib.SGMLParseError: 1332 pass 1333 1334 return s.gettext() 1335 1336#------------------------------------------------------------------------------ 1337 1338## EOF ## 1339