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