Code

PGP support is again working (pyme API has changed significantly) and we
[roundup.git] / roundup / mailgw.py
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
7 #
8 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
9 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
10 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
11 # POSSIBILITY OF SUCH DAMAGE.
12 #
13 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
14 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
15 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
16 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
17 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
18 #
20 """An e-mail gateway for Roundup.
22 Incoming messages are examined for multiple parts:
23  . In a multipart/mixed message or part, each subpart is extracted and
24    examined. The text/plain subparts are assembled to form the textual
25    body of the message, to be stored in the file associated with a "msg"
26    class node. Any parts of other types are each stored in separate files
27    and given "file" class nodes that are linked to the "msg" node.
28  . In a multipart/alternative message or part, we look for a text/plain
29    subpart and ignore the other parts.
30  . A message/rfc822 is treated similar tomultipart/mixed (except for
31    special handling of the first text part) if unpack_rfc822 is set in
32    the mailgw config section.
34 Summary
35 -------
36 The "summary" property on message nodes is taken from the first non-quoting
37 section in the message body. The message body is divided into sections by
38 blank lines. Sections where the second and all subsequent lines begin with
39 a ">" or "|" character are considered "quoting sections". The first line of
40 the first non-quoting section becomes the summary of the message.
42 Addresses
43 ---------
44 All of the addresses in the To: and Cc: headers of the incoming message are
45 looked up among the user nodes, and the corresponding users are placed in
46 the "recipients" property on the new "msg" node. The address in the From:
47 header similarly determines the "author" property of the new "msg"
48 node. The default handling for addresses that don't have corresponding
49 users is to create new users with no passwords and a username equal to the
50 address. (The web interface does not permit logins for users with no
51 passwords.) If we prefer to reject mail from outside sources, we can simply
52 register an auditor on the "user" class that prevents the creation of user
53 nodes with no passwords.
55 Actions
56 -------
57 The subject line of the incoming message is examined to determine whether
58 the message is an attempt to create a new item or to discuss an existing
59 item. A designator enclosed in square brackets is sought as the first thing
60 on the subject line (after skipping any "Fwd:" or "Re:" prefixes).
62 If an item designator (class name and id number) is found there, the newly
63 created "msg" node is added to the "messages" property for that item, and
64 any new "file" nodes are added to the "files" property for the item.
66 If just an item class name is found there, we attempt to create a new item
67 of that class with its "messages" property initialized to contain the new
68 "msg" node and its "files" property initialized to contain any new "file"
69 nodes.
71 Triggers
72 --------
73 Both cases may trigger detectors (in the first case we are calling the
74 set() method to add the message to the item's spool; in the second case we
75 are calling the create() method to create a new node). If an auditor raises
76 an exception, the original message is bounced back to the sender with the
77 explanatory message given in the exception.
79 $Id: mailgw.py,v 1.196 2008-07-23 03:04:44 richard Exp $
80 """
81 __docformat__ = 'restructuredtext'
83 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
84 import time, random, sys, logging
85 import traceback, rfc822
87 from email.Header import decode_header
89 from roundup import configuration, hyperdb, date, password, rfc2822, exceptions
90 from roundup.mailer import Mailer, MessageSendError
91 from roundup.i18n import _
92 from roundup.hyperdb import iter_roles
94 try:
95     import pyme, pyme.core, pyme.constants, pyme.constants.sigsum
96 except ImportError:
97     pyme = None
99 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
101 class MailGWError(ValueError):
102     pass
104 class MailUsageError(ValueError):
105     pass
107 class MailUsageHelp(Exception):
108     """ We need to send the help message to the user. """
109     pass
111 class Unauthorized(Exception):
112     """ Access denied """
113     pass
115 class IgnoreMessage(Exception):
116     """ A general class of message that we should ignore. """
117     pass
118 class IgnoreBulk(IgnoreMessage):
119         """ This is email from a mailing list or from a vacation program. """
120         pass
121 class IgnoreLoop(IgnoreMessage):
122         """ We've seen this message before... """
123         pass
125 def initialiseSecurity(security):
126     ''' Create some Permissions and Roles on the security object
128         This function is directly invoked by security.Security.__init__()
129         as a part of the Security object instantiation.
130     '''
131     p = security.addPermission(name="Email Access",
132         description="User may use the email interface")
133     security.addPermissionToRole('Admin', p)
135 def getparam(str, param):
136     ''' From the rfc822 "header" string, extract "param" if it appears.
137     '''
138     if ';' not in str:
139         return None
140     str = str[str.index(';'):]
141     while str[:1] == ';':
142         str = str[1:]
143         if ';' in str:
144             # XXX Should parse quotes!
145             end = str.index(';')
146         else:
147             end = len(str)
148         f = str[:end]
149         if '=' in f:
150             i = f.index('=')
151             if f[:i].strip().lower() == param:
152                 return rfc822.unquote(f[i+1:].strip())
153     return None
155 def gpgh_key_getall(key, attr):
156     ''' return list of given attribute for all uids in
157         a key
158     '''
159     for u in key.uids:
160         yield getattr(u, attr)
162 def check_pgp_sigs(sigs, gpgctx, author, may_be_unsigned=False):
163     ''' Theoretically a PGP message can have several signatures. GPGME
164         returns status on all signatures in a list. Walk that list
165         looking for the author's signature. Note that even if incoming
166         signatures are not required, the processing fails if there is an
167         invalid signature.
168     '''
169     for sig in sigs:
170         key = gpgctx.get_key(sig.fpr, False)
171         # we really only care about the signature of the user who
172         # submitted the email
173         if key and (author in gpgh_key_getall(key, 'email')):
174             if sig.summary & pyme.constants.sigsum.VALID:
175                 return True
176             else:
177                 # try to narrow down the actual problem to give a more useful
178                 # message in our bounce
179                 if sig.summary & pyme.constants.sigsum.KEY_MISSING:
180                     raise MailUsageError, \
181                         _("Message signed with unknown key: %s") % sig.fpr
182                 elif sig.summary & pyme.constants.sigsum.KEY_EXPIRED:
183                     raise MailUsageError, \
184                         _("Message signed with an expired key: %s") % sig.fpr
185                 elif sig.summary & pyme.constants.sigsum.KEY_REVOKED:
186                     raise MailUsageError, \
187                         _("Message signed with a revoked key: %s") % sig.fpr
188                 else:
189                     raise MailUsageError, \
190                         _("Invalid PGP signature detected.")
192     # we couldn't find a key belonging to the author of the email
193     if sigs:
194         raise MailUsageError, _("Message signed with unknown key: %s") % sig.fpr
195     elif not may_be_unsigned:
196         raise MailUsageError, _("Unsigned Message")
198 class Message(mimetools.Message):
199     ''' subclass mimetools.Message so we can retrieve the parts of the
200         message...
201     '''
202     def getpart(self):
203         ''' Get a single part of a multipart message and return it as a new
204             Message instance.
205         '''
206         boundary = self.getparam('boundary')
207         mid, end = '--'+boundary, '--'+boundary+'--'
208         s = cStringIO.StringIO()
209         while 1:
210             line = self.fp.readline()
211             if not line:
212                 break
213             if line.strip() in (mid, end):
214                 # according to rfc 1431 the preceding line ending is part of
215                 # the boundary so we need to strip that
216                 length = s.tell()
217                 s.seek(-2, 1)
218                 lineending = s.read(2)
219                 if lineending == '\r\n':
220                     s.truncate(length - 2)
221                 elif lineending[1] in ('\r', '\n'):
222                     s.truncate(length - 1)
223                 else:
224                     raise ValueError('Unknown line ending in message.')
225                 break
226             s.write(line)
227         if not s.getvalue().strip():
228             return None
229         s.seek(0)
230         return Message(s)
232     def getparts(self):
233         """Get all parts of this multipart message."""
234         # skip over the intro to the first boundary
235         self.fp.seek(0)
236         self.getpart()
238         # accumulate the other parts
239         parts = []
240         while 1:
241             part = self.getpart()
242             if part is None:
243                 break
244             parts.append(part)
245         return parts
247     def _decode_header_to_utf8(self, hdr):
248         l = []
249         prev_encoded = False
250         for part, encoding in decode_header(hdr):
251             if encoding:
252                 part = part.decode(encoding)
253             # RFC 2047 specifies that between encoded parts spaces are
254             # swallowed while at the borders from encoded to non-encoded
255             # or vice-versa we must preserve a space. Multiple adjacent
256             # non-encoded parts should not occur.
257             if l and prev_encoded != bool(encoding):
258                 l.append(' ')
259             prev_encoded = bool(encoding)
260             l.append(part)
261         return ''.join([s.encode('utf-8') for s in l])
263     def getheader(self, name, default=None):
264         hdr = mimetools.Message.getheader(self, name, default)
265         # TODO are there any other False values possible?
266         # TODO if not hdr: return hdr
267         if hdr is None:
268             return None
269         if not hdr:
270             return ''
271         if hdr:
272             hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
273         return self._decode_header_to_utf8(hdr)
275     def getaddrlist(self, name):
276         # overload to decode the name part of the address
277         l = []
278         for (name, addr) in mimetools.Message.getaddrlist(self, name):
279             name = self._decode_header_to_utf8(name)
280             l.append((name, addr))
281         return l
283     def getname(self):
284         """Find an appropriate name for this message."""
285         name = None
286         if self.gettype() == 'message/rfc822':
287             # handle message/rfc822 specially - the name should be
288             # the subject of the actual e-mail embedded here
289             # we add a '.eml' extension like other email software does it
290             self.fp.seek(0)
291             s = cStringIO.StringIO(self.getbody())
292             name = Message(s).getheader('subject')
293             if name:
294                 name = name + '.eml'
295         if not name:
296             # try name on Content-Type
297             name = self.getparam('name')
298             if not name:
299                 disp = self.getheader('content-disposition', None)
300                 if disp:
301                     name = getparam(disp, 'filename')
303         if name:
304             return name.strip()
306     def getbody(self):
307         """Get the decoded message body."""
308         self.rewindbody()
309         encoding = self.getencoding()
310         data = None
311         if encoding == 'base64':
312             # BUG: is base64 really used for text encoding or
313             # are we inserting zip files here.
314             data = binascii.a2b_base64(self.fp.read())
315         elif encoding == 'quoted-printable':
316             # the quopri module wants to work with files
317             decoded = cStringIO.StringIO()
318             quopri.decode(self.fp, decoded)
319             data = decoded.getvalue()
320         elif encoding == 'uuencoded':
321             data = binascii.a2b_uu(self.fp.read())
322         else:
323             # take it as text
324             data = self.fp.read()
326         # Encode message to unicode
327         charset = rfc2822.unaliasCharset(self.getparam("charset"))
328         if charset:
329             # Do conversion only if charset specified - handle
330             # badly-specified charsets
331             edata = unicode(data, charset, 'replace').encode('utf-8')
332             # Convert from dos eol to unix
333             edata = edata.replace('\r\n', '\n')
334         else:
335             # Leave message content as is
336             edata = data
338         return edata
340     # General multipart handling:
341     #   Take the first text/plain part, anything else is considered an
342     #   attachment.
343     # multipart/mixed:
344     #   Multiple "unrelated" parts.
345     # multipart/Alternative (rfc 1521):
346     #   Like multipart/mixed, except that we'd only want one of the
347     #   alternatives. Generally a top-level part from MUAs sending HTML
348     #   mail - there will be a text/plain version.
349     # multipart/signed (rfc 1847):
350     #   The control information is carried in the second of the two
351     #   required body parts.
352     #   ACTION: Default, so if content is text/plain we get it.
353     # multipart/encrypted (rfc 1847):
354     #   The control information is carried in the first of the two
355     #   required body parts.
356     #   ACTION: Not handleable as the content is encrypted.
357     # multipart/related (rfc 1872, 2112, 2387):
358     #   The Multipart/Related content-type addresses the MIME
359     #   representation of compound objects, usually HTML mail with embedded
360     #   images. Usually appears as an alternative.
361     #   ACTION: Default, if we must.
362     # multipart/report (rfc 1892):
363     #   e.g. mail system delivery status reports.
364     #   ACTION: Default. Could be ignored or used for Delivery Notification
365     #   flagging.
366     # multipart/form-data:
367     #   For web forms only.
368     # message/rfc822:
369     #   Only if configured in [mailgw] unpack_rfc822
371     def extract_content(self, parent_type=None, ignore_alternatives=False,
372         unpack_rfc822=False):
373         """Extract the body and the attachments recursively.
375            If the content is hidden inside a multipart/alternative part,
376            we use the *last* text/plain part of the *first*
377            multipart/alternative in the whole message.
378         """
379         content_type = self.gettype()
380         content = None
381         attachments = []
383         if content_type == 'text/plain':
384             content = self.getbody()
385         elif content_type[:10] == 'multipart/':
386             content_found = bool (content)
387             ig = ignore_alternatives and not content_found
388             for part in self.getparts():
389                 new_content, new_attach = part.extract_content(content_type,
390                     not content and ig, unpack_rfc822)
392                 # If we haven't found a text/plain part yet, take this one,
393                 # otherwise make it an attachment.
394                 if not content:
395                     content = new_content
396                     cpart   = part
397                 elif new_content:
398                     if content_found or content_type != 'multipart/alternative':
399                         attachments.append(part.text_as_attachment())
400                     else:
401                         # if we have found a text/plain in the current
402                         # multipart/alternative and find another one, we
403                         # use the first as an attachment (if configured)
404                         # and use the second one because rfc 2046, sec.
405                         # 5.1.4. specifies that later parts are better
406                         # (thanks to Philipp Gortan for pointing this
407                         # out)
408                         attachments.append(cpart.text_as_attachment())
409                         content = new_content
410                         cpart   = part
412                 attachments.extend(new_attach)
413             if ig and content_type == 'multipart/alternative' and content:
414                 attachments = []
415         elif unpack_rfc822 and content_type == 'message/rfc822':
416             s = cStringIO.StringIO(self.getbody())
417             m = Message(s)
418             ig = ignore_alternatives and not content
419             new_content, attachments = m.extract_content(m.gettype(), ig,
420                 unpack_rfc822)
421             attachments.insert(0, m.text_as_attachment())
422         elif (parent_type == 'multipart/signed' and
423               content_type == 'application/pgp-signature'):
424             # ignore it so it won't be saved as an attachment
425             pass
426         else:
427             attachments.append(self.as_attachment())
428         return content, attachments
430     def text_as_attachment(self):
431         """Return first text/plain part as Message"""
432         if not self.gettype().startswith ('multipart/'):
433             return self.as_attachment()
434         for part in self.getparts():
435             content_type = part.gettype()
436             if content_type == 'text/plain':
437                 return part.as_attachment()
438             elif content_type.startswith ('multipart/'):
439                 p = part.text_as_attachment()
440                 if p:
441                     return p
442         return None
444     def as_attachment(self):
445         """Return this message as an attachment."""
446         return (self.getname(), self.gettype(), self.getbody())
448     def pgp_signed(self):
449         ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
450         '''
451         return self.gettype() == 'multipart/signed' \
452             and self.typeheader.find('protocol="application/pgp-signature"') != -1
454     def pgp_encrypted(self):
455         ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
456         '''
457         return self.gettype() == 'multipart/encrypted' \
458             and self.typeheader.find('protocol="application/pgp-encrypted"') != -1
460     def decrypt(self, author, may_be_unsigned=False):
461         ''' decrypt an OpenPGP MIME message
462             This message must be signed as well as encrypted using the
463             "combined" method if incoming signatures are configured.
464             The decrypted contents are returned as a new message.
465         '''
466         (hdr, msg) = self.getparts()
467         # According to the RFC 3156 encrypted mail must have exactly two parts.
468         # The first part contains the control information. Let's verify that
469         # the message meets the RFC before we try to decrypt it.
470         if hdr.getbody().strip() != 'Version: 1' \
471            or hdr.gettype() != 'application/pgp-encrypted':
472             raise MailUsageError, \
473                 _("Unknown multipart/encrypted version.")
475         context = pyme.core.Context()
476         ciphertext = pyme.core.Data(msg.getbody())
477         plaintext = pyme.core.Data()
479         result = context.op_decrypt_verify(ciphertext, plaintext)
481         if result:
482             raise MailUsageError, _("Unable to decrypt your message.")
484         # we've decrypted it but that just means they used our public
485         # key to send it to us. now check the signatures to see if it
486         # was signed by someone we trust
487         result = context.op_verify_result()
488         check_pgp_sigs(result.signatures, context, author,
489             may_be_unsigned = may_be_unsigned)
491         plaintext.seek(0,0)
492         # pyme.core.Data implements a seek method with a different signature
493         # than roundup can handle. So we'll put the data in a container that
494         # the Message class can work with.
495         c = cStringIO.StringIO()
496         c.write(plaintext.read())
497         c.seek(0)
498         return Message(c)
500     def verify_signature(self, author):
501         ''' verify the signature of an OpenPGP MIME message
502             This only handles detached signatures. Old style
503             PGP mail (i.e. '-----BEGIN PGP SIGNED MESSAGE----')
504             is archaic and not supported :)
505         '''
506         # we don't check the micalg parameter...gpgme seems to
507         # figure things out on its own
508         (msg, sig) = self.getparts()
510         if sig.gettype() != 'application/pgp-signature':
511             raise MailUsageError, \
512                 _("No PGP signature found in message.")
514         # msg.getbody() is skipping over some headers that are
515         # required to be present for verification to succeed so
516         # we'll do this by hand
517         msg.fp.seek(0)
518         # according to rfc 3156 the data "MUST first be converted
519         # to its content-type specific canonical form. For
520         # text/plain this means conversion to an appropriate
521         # character set and conversion of line endings to the
522         # canonical <CR><LF> sequence."
523         # TODO: what about character set conversion?
524         canonical_msg = re.sub('(?<!\r)\n', '\r\n', msg.fp.read())
525         msg_data = pyme.core.Data(canonical_msg)
526         sig_data = pyme.core.Data(sig.getbody())
528         context = pyme.core.Context()
529         context.op_verify(sig_data, msg_data, None)
531         # check all signatures for validity
532         result = context.op_verify_result()
533         check_pgp_sigs(result.signatures, context, author)
535 class parsedMessage:
537     def __init__(self, mailgw, message):
538         self.mailgw = mailgw
539         self.config = mailgw.instance.config
540         self.db = mailgw.db
541         self.message = message
542         self.subject = message.getheader('subject', '')
543         self.has_prefix = False
544         self.matches = dict.fromkeys(['refwd', 'quote', 'classname',
545                                  'nodeid', 'title', 'args', 'argswhole'])
546         self.from_list = message.getaddrlist('resent-from') \
547                          or message.getaddrlist('from')
548         self.pfxmode = self.config['MAILGW_SUBJECT_PREFIX_PARSING']
549         self.sfxmode = self.config['MAILGW_SUBJECT_SUFFIX_PARSING']
550         # these are filled in by subsequent parsing steps
551         self.classname = None
552         self.properties = None
553         self.cl = None
554         self.nodeid = None
555         self.author = None
556         self.recipients = None
557         self.msg_props = {}
558         self.props = None
559         self.content = None
560         self.attachments = None
561         self.crypt = False
563     def handle_ignore(self):
564         ''' Check to see if message can be safely ignored:
565             detect loops and
566             Precedence: Bulk, or Microsoft Outlook autoreplies
567         '''
568         if self.message.getheader('x-roundup-loop', ''):
569             raise IgnoreLoop
570         if (self.message.getheader('precedence', '') == 'bulk'
571                 or self.subject.lower().find("autoreply") > 0):
572             raise IgnoreBulk
574     def handle_help(self):
575         ''' Check to see if the message contains a usage/help request
576         '''
577         if self.subject.strip().lower() == 'help':
578             raise MailUsageHelp
580     def check_subject(self):
581         ''' Check to see if the message contains a valid subject line
582         '''
583         if not self.subject:
584             raise MailUsageError, _("""
585 Emails to Roundup trackers must include a Subject: line!
586 """)
588     def parse_subject(self):
589         ''' Matches subjects like:
590         Re: "[issue1234] title of issue [status=resolved]"
591         
592         Each part of the subject is matched, stored, then removed from the
593         start of the subject string as needed. The stored values are then
594         returned
595         '''
597         tmpsubject = self.subject
599         sd_open, sd_close = self.config['MAILGW_SUBJECT_SUFFIX_DELIMITERS']
600         delim_open = re.escape(sd_open)
601         if delim_open in '[(': delim_open = '\\' + delim_open
602         delim_close = re.escape(sd_close)
603         if delim_close in '[(': delim_close = '\\' + delim_close
605         # Look for Re: et. al. Used later on for MAILGW_SUBJECT_CONTENT_MATCH
606         re_re = r"(?P<refwd>%s)\s*" % self.config["MAILGW_REFWD_RE"].pattern
607         m = re.match(re_re, tmpsubject, re.IGNORECASE|re.VERBOSE|re.UNICODE)
608         if m:
609             m = m.groupdict()
610             if m['refwd']:
611                 self.matches.update(m)
612                 tmpsubject = tmpsubject[len(m['refwd']):] # Consume Re:
614         # Look for Leading "
615         m = re.match(r'(?P<quote>\s*")', tmpsubject,
616                      re.IGNORECASE)
617         if m:
618             self.matches.update(m.groupdict())
619             tmpsubject = tmpsubject[len(self.matches['quote']):] # Consume quote
621         # Check if the subject includes a prefix
622         self.has_prefix = re.search(r'^%s(\w+)%s'%(delim_open,
623             delim_close), tmpsubject.strip())
625         # Match the classname if specified
626         class_re = r'%s(?P<classname>(%s))(?P<nodeid>\d+)?%s'%(delim_open,
627             "|".join(self.db.getclasses()), delim_close)
628         # Note: re.search, not re.match as there might be garbage
629         # (mailing list prefix, etc.) before the class identifier
630         m = re.search(class_re, tmpsubject, re.IGNORECASE)
631         if m:
632             self.matches.update(m.groupdict())
633             # Skip to the end of the class identifier, including any
634             # garbage before it.
636             tmpsubject = tmpsubject[m.end():]
638         # Match the title of the subject
639         # if we've not found a valid classname prefix then force the
640         # scanning to handle there being a leading delimiter
641         title_re = r'(?P<title>%s[^%s]*)'%(
642             not self.matches['classname'] and '.' or '', delim_open)
643         m = re.match(title_re, tmpsubject.strip(), re.IGNORECASE)
644         if m:
645             self.matches.update(m.groupdict())
646             tmpsubject = tmpsubject[len(self.matches['title']):] # Consume title
648         if self.matches['title']:
649             self.matches['title'] = self.matches['title'].strip()
650         else:
651             self.matches['title'] = ''
653         # strip off the quotes that dumb emailers put around the subject, like
654         #      Re: "[issue1] bla blah"
655         if self.matches['quote'] and self.matches['title'].endswith('"'):
656             self.matches['title'] = self.matches['title'][:-1]
657         
658         # Match any arguments specified
659         args_re = r'(?P<argswhole>%s(?P<args>.+?)%s)?'%(delim_open,
660             delim_close)
661         m = re.search(args_re, tmpsubject.strip(), re.IGNORECASE|re.VERBOSE)
662         if m:
663             self.matches.update(m.groupdict())
665     def rego_confirm(self):
666         ''' Check for registration OTK and confirm the registration if found
667         '''
668         
669         if self.config['EMAIL_REGISTRATION_CONFIRMATION']:
670             otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
671             otk = otk_re.search(self.matches['title'] or '')
672             if otk:
673                 self.db.confirm_registration(otk.group('otk'))
674                 subject = 'Your registration to %s is complete' % \
675                           self.config['TRACKER_NAME']
676                 sendto = [self.from_list[0][1]]
677                 self.mailgw.mailer.standard_message(sendto, subject, '')
678                 return 1
679         return 0
681     def get_classname(self):
682         ''' Determine the classname of the node being created/edited
683         '''
684         subject = self.subject
686         # get the classname
687         if self.pfxmode == 'none':
688             classname = None
689         else:
690             classname = self.matches['classname']
692         if not classname and self.has_prefix and self.pfxmode == 'strict':
693             raise MailUsageError, _("""
694 The message you sent to roundup did not contain a properly formed subject
695 line. The subject must contain a class name or designator to indicate the
696 'topic' of the message. For example:
697     Subject: [issue] This is a new issue
698       - this will create a new issue in the tracker with the title 'This is
699         a new issue'.
700     Subject: [issue1234] This is a followup to issue 1234
701       - this will append the message's contents to the existing issue 1234
702         in the tracker.
704 Subject was: '%(subject)s'
705 """) % locals()
707         # try to get the class specified - if "loose" or "none" then fall
708         # back on the default
709         attempts = []
710         if classname:
711             attempts.append(classname)
713         if self.mailgw.default_class:
714             attempts.append(self.mailgw.default_class)
715         else:
716             attempts.append(self.config['MAILGW_DEFAULT_CLASS'])
718         # first valid class name wins
719         self.cl = None
720         for trycl in attempts:
721             try:
722                 self.cl = self.db.getclass(trycl)
723                 classname = self.classname = trycl
724                 break
725             except KeyError:
726                 pass
728         if not self.cl:
729             validname = ', '.join(self.db.getclasses())
730             if classname:
731                 raise MailUsageError, _("""
732 The class name you identified in the subject line ("%(classname)s") does
733 not exist in the database.
735 Valid class names are: %(validname)s
736 Subject was: "%(subject)s"
737 """) % locals()
738             else:
739                 raise MailUsageError, _("""
740 You did not identify a class name in the subject line and there is no
741 default set for this tracker. The subject must contain a class name or
742 designator to indicate the 'topic' of the message. For example:
743     Subject: [issue] This is a new issue
744       - this will create a new issue in the tracker with the title 'This is
745         a new issue'.
746     Subject: [issue1234] This is a followup to issue 1234
747       - this will append the message's contents to the existing issue 1234
748         in the tracker.
750 Subject was: '%(subject)s'
751 """) % locals()
752         # get the class properties
753         self.properties = self.cl.getprops()
754         
756     def get_nodeid(self):
757         ''' Determine the nodeid from the message and return it if found
758         '''
759         title = self.matches['title']
760         subject = self.subject
761         
762         if self.pfxmode == 'none':
763             nodeid = None
764         else:
765             nodeid = self.matches['nodeid']
767         # try in-reply-to to match the message if there's no nodeid
768         inreplyto = self.message.getheader('in-reply-to') or ''
769         if nodeid is None and inreplyto:
770             l = self.db.getclass('msg').stringFind(messageid=inreplyto)
771             if l:
772                 nodeid = self.cl.filter(None, {'messages':l})[0]
775         # but we do need either a title or a nodeid...
776         if nodeid is None and not title:
777             raise MailUsageError, _("""
778 I cannot match your message to a node in the database - you need to either
779 supply a full designator (with number, eg "[issue123]") or keep the
780 previous subject title intact so I can match that.
782 Subject was: "%(subject)s"
783 """) % locals()
785         # If there's no nodeid, check to see if this is a followup and
786         # maybe someone's responded to the initial mail that created an
787         # entry. Try to find the matching nodes with the same title, and
788         # use the _last_ one matched (since that'll _usually_ be the most
789         # recent...). The subject_content_match config may specify an
790         # additional restriction based on the matched node's creation or
791         # activity.
792         tmatch_mode = self.config['MAILGW_SUBJECT_CONTENT_MATCH']
793         if tmatch_mode != 'never' and nodeid is None and self.matches['refwd']:
794             l = self.cl.stringFind(title=title)
795             limit = None
796             if (tmatch_mode.startswith('creation') or
797                     tmatch_mode.startswith('activity')):
798                 limit, interval = tmatch_mode.split(' ', 1)
799                 threshold = date.Date('.') - date.Interval(interval)
800             for id in l:
801                 if limit:
802                     if threshold < self.cl.get(id, limit):
803                         nodeid = id
804                 else:
805                     nodeid = id
807         # if a nodeid was specified, make sure it's valid
808         if nodeid is not None and not self.cl.hasnode(nodeid):
809             if self.pfxmode == 'strict':
810                 raise MailUsageError, _("""
811 The node specified by the designator in the subject of your message
812 ("%(nodeid)s") does not exist.
814 Subject was: "%(subject)s"
815 """) % locals()
816             else:
817                 nodeid = None
818         self.nodeid = nodeid
820     def get_author_id(self):
821         ''' Attempt to get the author id from the existing registered users,
822             otherwise attempt to register a new user and return their id
823         '''
824         # Don't create users if anonymous isn't allowed to register
825         create = 1
826         anonid = self.db.user.lookup('anonymous')
827         if not (self.db.security.hasPermission('Register', anonid, 'user')
828                 and self.db.security.hasPermission('Email Access', anonid)):
829             create = 0
831         # ok, now figure out who the author is - create a new user if the
832         # "create" flag is true
833         author = uidFromAddress(self.db, self.from_list[0], create=create)
835         # if we're not recognised, and we don't get added as a user, then we
836         # must be anonymous
837         if not author:
838             author = anonid
840         # make sure the author has permission to use the email interface
841         if not self.db.security.hasPermission('Email Access', author):
842             if author == anonid:
843                 # we're anonymous and we need to be a registered user
844                 from_address = self.from_list[0][1]
845                 registration_info = ""
846                 if self.db.security.hasPermission('Web Access', author) and \
847                    self.db.security.hasPermission('Register', anonid, 'user'):
848                     tracker_web = self.config.TRACKER_WEB
849                     registration_info = """ Please register at:
851 %(tracker_web)suser?template=register
853 ...before sending mail to the tracker.""" % locals()
855                 raise Unauthorized, _("""
856 You are not a registered user.%(registration_info)s
858 Unknown address: %(from_address)s
859 """) % locals()
860             else:
861                 # we're registered and we're _still_ not allowed access
862                 raise Unauthorized, _(
863                     'You are not permitted to access this tracker.')
864         self.author = author
866     def check_permissions(self):
867         ''' Check if the author has permission to edit or create this
868             class of node
869         '''
870         if self.nodeid:
871             if not self.db.security.hasPermission('Edit', self.author,
872                     self.classname, itemid=self.nodeid):
873                 raise Unauthorized, _(
874                     'You are not permitted to edit %(classname)s.'
875                     ) % self.__dict__
876         else:
877             if not self.db.security.hasPermission('Create', self.author,
878                     self.classname):
879                 raise Unauthorized, _(
880                     'You are not permitted to create %(classname)s.'
881                     ) % self.__dict__
883     def commit_and_reopen_as_author(self):
884         ''' the author may have been created - make sure the change is
885             committed before we reopen the database
886             then re-open the database as the author
887         '''
888         self.db.commit()
890         # set the database user as the author
891         username = self.db.user.get(self.author, 'username')
892         self.db.setCurrentUser(username)
894         # re-get the class with the new database connection
895         self.cl = self.db.getclass(self.classname)
897     def get_recipients(self):
898         ''' Get the list of recipients who were included in message and
899             register them as users if possible
900         '''
901         # Don't create users if anonymous isn't allowed to register
902         create = 1
903         anonid = self.db.user.lookup('anonymous')
904         if not (self.db.security.hasPermission('Register', anonid, 'user')
905                 and self.db.security.hasPermission('Email Access', anonid)):
906             create = 0
908         # get the user class arguments from the commandline
909         user_props = self.mailgw.get_class_arguments('user')
911         # now update the recipients list
912         recipients = []
913         tracker_email = self.config['TRACKER_EMAIL'].lower()
914         msg_to = self.message.getaddrlist('to')
915         msg_cc = self.message.getaddrlist('cc')
916         for recipient in msg_to + msg_cc:
917             r = recipient[1].strip().lower()
918             if r == tracker_email or not r:
919                 continue
921             # look up the recipient - create if necessary (and we're
922             # allowed to)
923             recipient = uidFromAddress(self.db, recipient, create, **user_props)
925             # if all's well, add the recipient to the list
926             if recipient:
927                 recipients.append(recipient)
928         self.recipients = recipients
930     def get_props(self):
931         ''' Generate all the props for the new/updated node and return them
932         '''
933         subject = self.subject
934         
935         # get the commandline arguments for issues
936         issue_props = self.mailgw.get_class_arguments('issue', self.classname)
937         
938         #
939         # handle the subject argument list
940         #
941         # figure what the properties of this Class are
942         props = {}
943         args = self.matches['args']
944         argswhole = self.matches['argswhole']
945         title = self.matches['title']
946         
947         # Reform the title 
948         if self.matches['nodeid'] and self.nodeid is None:
949             title = subject
950         
951         if args:
952             if self.sfxmode == 'none':
953                 title += ' ' + argswhole
954             else:
955                 errors, props = setPropArrayFromString(self, self.cl, args,
956                     self.nodeid)
957                 # handle any errors parsing the argument list
958                 if errors:
959                     if self.sfxmode == 'strict':
960                         errors = '\n- '.join(map(str, errors))
961                         raise MailUsageError, _("""
962 There were problems handling your subject line argument list:
963 - %(errors)s
965 Subject was: "%(subject)s"
966 """) % locals()
967                     else:
968                         title += ' ' + argswhole
971         # set the issue title to the subject
972         title = title.strip()
973         if (title and self.properties.has_key('title') and not
974                 issue_props.has_key('title')):
975             issue_props['title'] = title
976         if (self.nodeid and self.properties.has_key('title') and not
977                 self.config['MAILGW_SUBJECT_UPDATES_TITLE']):
978             issue_props['title'] = self.cl.get(self.nodeid,'title')
980         # merge the command line props defined in issue_props into
981         # the props dictionary because function(**props, **issue_props)
982         # is a syntax error.
983         for prop in issue_props.keys() :
984             if not props.has_key(prop) :
985                 props[prop] = issue_props[prop]
987         self.props = props
989     def get_pgp_message(self):
990         ''' If they've enabled PGP processing then verify the signature
991             or decrypt the message
992         '''
993         def pgp_role():
994             """ if PGP_ROLES is specified the user must have a Role in the list
995                 or we will skip PGP processing
996             """
997             if self.config.PGP_ROLES:
998                 return self.db.user.has_role(self.author,
999                     *iter_roles(self.config.PGP_ROLES))
1000             else:
1001                 return True
1003         if self.config.PGP_ENABLE:
1004             if pgp_role() and self.config.PGP_ENCRYPT:
1005                 self.crypt = True
1006             assert pyme, 'pyme is not installed'
1007             # signed/encrypted mail must come from the primary address
1008             author_address = self.db.user.get(self.author, 'address')
1009             if self.config.PGP_HOMEDIR:
1010                 os.environ['GNUPGHOME'] = self.config.PGP_HOMEDIR
1011             if self.config.PGP_REQUIRE_INCOMING in ('encrypted', 'both') \
1012                 and pgp_role() and not self.message.pgp_encrypted():
1013                 raise MailUsageError, _(
1014                     "This tracker has been configured to require all email "
1015                     "be PGP encrypted.")
1016             if self.message.pgp_signed():
1017                 self.message.verify_signature(author_address)
1018             elif self.message.pgp_encrypted():
1019                 # Replace message with the contents of the decrypted
1020                 # message for content extraction
1021                 # Note: the bounce-handling code now makes sure that
1022                 # either the encrypted mail received is sent back or
1023                 # that the error message is encrypted if needed.
1024                 encr_only = self.config.PGP_REQUIRE_INCOMING == 'encrypted'
1025                 encr_only = encr_only or not pgp_role()
1026                 self.crypt = True
1027                 self.message = self.message.decrypt(author_address,
1028                     may_be_unsigned = encr_only)
1029             elif pgp_role():
1030                 raise MailUsageError, _("""
1031 This tracker has been configured to require all email be PGP signed or
1032 encrypted.""")
1034     def get_content_and_attachments(self):
1035         ''' get the attachments and first text part from the message
1036         '''
1037         ig = self.config.MAILGW_IGNORE_ALTERNATIVES
1038         self.content, self.attachments = self.message.extract_content(
1039             ignore_alternatives=ig,
1040             unpack_rfc822=self.config.MAILGW_UNPACK_RFC822)
1041         
1043     def create_files(self):
1044         ''' Create a file for each attachment in the message
1045         '''
1046         if not self.properties.has_key('files'):
1047             return
1048         files = []
1049         file_props = self.mailgw.get_class_arguments('file')
1050         
1051         if self.attachments:
1052             for (name, mime_type, data) in self.attachments:
1053                 if not self.db.security.hasPermission('Create', self.author,
1054                     'file'):
1055                     raise Unauthorized, _(
1056                         'You are not permitted to create files.')
1057                 if not name:
1058                     name = "unnamed"
1059                 try:
1060                     fileid = self.db.file.create(type=mime_type, name=name,
1061                          content=data, **file_props)
1062                 except exceptions.Reject:
1063                     pass
1064                 else:
1065                     files.append(fileid)
1066             # allowed to attach the files to an existing node?
1067             if self.nodeid and not self.db.security.hasPermission('Edit',
1068                     self.author, self.classname, 'files'):
1069                 raise Unauthorized, _(
1070                     'You are not permitted to add files to %(classname)s.'
1071                     ) % self.__dict__
1073             self.msg_props['files'] = files
1074             if self.nodeid:
1075                 # extend the existing files list
1076                 fileprop = self.cl.get(self.nodeid, 'files')
1077                 fileprop.extend(files)
1078                 files = fileprop
1080             self.props['files'] = files
1082     def create_msg(self):
1083         ''' Create msg containing all the relevant information from the message
1084         '''
1085         if not self.properties.has_key('messages'):
1086             return
1087         msg_props = self.mailgw.get_class_arguments('msg')
1088         self.msg_props.update (msg_props)
1089         
1090         # Get the message ids
1091         inreplyto = self.message.getheader('in-reply-to') or ''
1092         messageid = self.message.getheader('message-id')
1093         # generate a messageid if there isn't one
1094         if not messageid:
1095             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
1096                 self.classname, self.nodeid, self.config['MAIL_DOMAIN'])
1097         
1098         if self.content is None:
1099             raise MailUsageError, _("""
1100 Roundup requires the submission to be plain text. The message parser could
1101 not find a text/plain part to use.
1102 """)
1104         # parse the body of the message, stripping out bits as appropriate
1105         summary, content = parseContent(self.content, config=self.config)
1106         content = content.strip()
1108         if content:
1109             if not self.db.security.hasPermission('Create', self.author, 'msg'):
1110                 raise Unauthorized, _(
1111                     'You are not permitted to create messages.')
1113             try:
1114                 message_id = self.db.msg.create(author=self.author,
1115                     recipients=self.recipients, date=date.Date('.'),
1116                     summary=summary, content=content,
1117                     messageid=messageid, inreplyto=inreplyto, **self.msg_props)
1118             except exceptions.Reject, error:
1119                 raise MailUsageError, _("""
1120 Mail message was rejected by a detector.
1121 %(error)s
1122 """) % locals()
1123             # allowed to attach the message to the existing node?
1124             if self.nodeid and not self.db.security.hasPermission('Edit',
1125                     self.author, self.classname, 'messages'):
1126                 raise Unauthorized, _(
1127                     'You are not permitted to add messages to %(classname)s.'
1128                     ) % self.__dict__
1130             if self.nodeid:
1131                 # add the message to the node's list
1132                 messages = self.cl.get(self.nodeid, 'messages')
1133                 messages.append(message_id)
1134                 self.props['messages'] = messages
1135             else:
1136                 # pre-load the messages list
1137                 self.props['messages'] = [message_id]
1139     def create_node(self):
1140         ''' Create/update a node using self.props 
1141         '''
1142         classname = self.classname
1143         try:
1144             if self.nodeid:
1145                 # Check permissions for each property
1146                 for prop in self.props.keys():
1147                     if not self.db.security.hasPermission('Edit', self.author,
1148                             classname, prop):
1149                         raise Unauthorized, _('You are not permitted to edit '
1150                             'property %(prop)s of class %(classname)s.'
1151                             ) % locals()
1152                 self.cl.set(self.nodeid, **self.props)
1153             else:
1154                 # Check permissions for each property
1155                 for prop in self.props.keys():
1156                     if not self.db.security.hasPermission('Create', self.author,
1157                             classname, prop):
1158                         raise Unauthorized, _('You are not permitted to set '
1159                             'property %(prop)s of class %(classname)s.'
1160                             ) % locals()
1161                 self.nodeid = self.cl.create(**self.props)
1162         except (TypeError, IndexError, ValueError, exceptions.Reject), message:
1163             raise MailUsageError, _("""
1164 There was a problem with the message you sent:
1165    %(message)s
1166 """) % locals()
1168         return self.nodeid
1170         # XXX Don't enable. This doesn't work yet.
1171 #  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
1172         # handle delivery to addresses like:tracker+issue25@some.dom.ain
1173         # use the embedded issue number as our issue
1174 #            issue_re = config['MAILGW_ISSUE_ADDRESS_RE']
1175 #            if issue_re:
1176 #                for header in ['to', 'cc', 'bcc']:
1177 #                    addresses = message.getheader(header, '')
1178 #                if addresses:
1179 #                  # FIXME, this only finds the first match in the addresses.
1180 #                    issue = re.search(issue_re, addresses, 'i')
1181 #                    if issue:
1182 #                        classname = issue.group('classname')
1183 #                        nodeid = issue.group('nodeid')
1184 #                        break
1186     # Default sequence of methods to be called on message. Use this for
1187     # easier override of the default message processing
1188     # list consists of tuples (method, return_if_true), the parsing
1189     # returns if the return_if_true flag is set for a method *and* the
1190     # method returns something that evaluates to True.
1191     method_list = [
1192         # Filter out messages to ignore
1193         (handle_ignore, False),
1194         # Check for usage/help requests
1195         (handle_help, False),
1196         # Check if the subject line is valid
1197         (check_subject, False),
1198         # get importants parts from subject
1199         (parse_subject, False),
1200         # check for registration OTK
1201         (rego_confirm, True),
1202         # get the classname
1203         (get_classname, False),
1204         # get the optional nodeid:
1205         (get_nodeid, False),
1206         # Determine who the author is:
1207         (get_author_id, False),
1208         # allowed to edit or create this class?
1209         (check_permissions, False),
1210         # author may have been created:
1211         # commit author to database and re-open as author
1212         (commit_and_reopen_as_author, False),
1213         # Get the recipients list
1214         (get_recipients, False),
1215         # get the new/updated node props
1216         (get_props, False),
1217         # Handle PGP signed or encrypted messages
1218         (get_pgp_message, False),
1219         # extract content and attachments from message body:
1220         (get_content_and_attachments, False),
1221         # put attachments into files linked to the issue:
1222         (create_files, False),
1223         # create the message if there's a message body (content):
1224         (create_msg, False),
1225     ]
1228     def parse (self):
1229         for method, flag in self.method_list:
1230             ret = method(self)
1231             if flag and ret:
1232                 return
1233         # perform the node change / create:
1234         return self.create_node()
1237 class MailGW:
1239     # To override the message parsing, derive your own class from
1240     # parsedMessage and assign to parsed_message_class in a derived
1241     # class of MailGW
1242     parsed_message_class = parsedMessage
1244     def __init__(self, instance, arguments=()):
1245         self.instance = instance
1246         self.arguments = arguments
1247         self.default_class = None
1248         for option, value in self.arguments:
1249             if option == '-c':
1250                 self.default_class = value.strip()
1252         self.mailer = Mailer(instance.config)
1253         self.logger = logging.getLogger('roundup.mailgw')
1255         # should we trap exceptions (normal usage) or pass them through
1256         # (for testing)
1257         self.trapExceptions = 1
1259     def do_pipe(self):
1260         """ Read a message from standard input and pass it to the mail handler.
1262             Read into an internal structure that we can seek on (in case
1263             there's an error).
1265             XXX: we may want to read this into a temporary file instead...
1266         """
1267         s = cStringIO.StringIO()
1268         s.write(sys.stdin.read())
1269         s.seek(0)
1270         self.main(s)
1271         return 0
1273     def do_mailbox(self, filename):
1274         """ Read a series of messages from the specified unix mailbox file and
1275             pass each to the mail handler.
1276         """
1277         # open the spool file and lock it
1278         import fcntl
1279         # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
1280         if hasattr(fcntl, 'LOCK_EX'):
1281             FCNTL = fcntl
1282         else:
1283             import FCNTL
1284         f = open(filename, 'r+')
1285         fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
1287         # handle and clear the mailbox
1288         try:
1289             from mailbox import UnixMailbox
1290             mailbox = UnixMailbox(f, factory=Message)
1291             # grab one message
1292             message = mailbox.next()
1293             while message:
1294                 # handle this message
1295                 self.handle_Message(message)
1296                 message = mailbox.next()
1297             # nuke the file contents
1298             os.ftruncate(f.fileno(), 0)
1299         except:
1300             import traceback
1301             traceback.print_exc()
1302             return 1
1303         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
1304         return 0
1306     def do_imap(self, server, user='', password='', mailbox='', ssl=0,
1307             cram=0):
1308         ''' Do an IMAP connection
1309         '''
1310         import getpass, imaplib, socket
1311         try:
1312             if not user:
1313                 user = raw_input('User: ')
1314             if not password:
1315                 password = getpass.getpass()
1316         except (KeyboardInterrupt, EOFError):
1317             # Ctrl C or D maybe also Ctrl Z under Windows.
1318             print "\nAborted by user."
1319             return 1
1320         # open a connection to the server and retrieve all messages
1321         try:
1322             if ssl:
1323                 self.logger.debug('Trying server %r with ssl'%server)
1324                 server = imaplib.IMAP4_SSL(server)
1325             else:
1326                 self.logger.debug('Trying server %r without ssl'%server)
1327                 server = imaplib.IMAP4(server)
1328         except (imaplib.IMAP4.error, socket.error, socket.sslerror):
1329             self.logger.exception('IMAP server error')
1330             return 1
1332         try:
1333             if cram:
1334                 server.login_cram_md5(user, password)
1335             else:
1336                 server.login(user, password)
1337         except imaplib.IMAP4.error, e:
1338             self.logger.exception('IMAP login failure')
1339             return 1
1341         try:
1342             if not mailbox:
1343                 (typ, data) = server.select()
1344             else:
1345                 (typ, data) = server.select(mailbox=mailbox)
1346             if typ != 'OK':
1347                 self.logger.error('Failed to get mailbox %r: %s'%(mailbox,
1348                     data))
1349                 return 1
1350             try:
1351                 numMessages = int(data[0])
1352             except ValueError, value:
1353                 self.logger.error('Invalid message count from mailbox %r'%
1354                     data[0])
1355                 return 1
1356             for i in range(1, numMessages+1):
1357                 (typ, data) = server.fetch(str(i), '(RFC822)')
1359                 # mark the message as deleted.
1360                 server.store(str(i), '+FLAGS', r'(\Deleted)')
1362                 # process the message
1363                 s = cStringIO.StringIO(data[0][1])
1364                 s.seek(0)
1365                 self.handle_Message(Message(s))
1366             server.close()
1367         finally:
1368             try:
1369                 server.expunge()
1370             except:
1371                 pass
1372             server.logout()
1374         return 0
1377     def do_apop(self, server, user='', password='', ssl=False):
1378         ''' Do authentication POP
1379         '''
1380         self._do_pop(server, user, password, True, ssl)
1382     def do_pop(self, server, user='', password='', ssl=False):
1383         ''' Do plain POP
1384         '''
1385         self._do_pop(server, user, password, False, ssl)
1387     def _do_pop(self, server, user, password, apop, ssl):
1388         '''Read a series of messages from the specified POP server.
1389         '''
1390         import getpass, poplib, socket
1391         try:
1392             if not user:
1393                 user = raw_input('User: ')
1394             if not password:
1395                 password = getpass.getpass()
1396         except (KeyboardInterrupt, EOFError):
1397             # Ctrl C or D maybe also Ctrl Z under Windows.
1398             print "\nAborted by user."
1399             return 1
1401         # open a connection to the server and retrieve all messages
1402         try:
1403             if ssl:
1404                 klass = poplib.POP3_SSL
1405             else:
1406                 klass = poplib.POP3
1407             server = klass(server)
1408         except socket.error:
1409             self.logger.exception('POP server error')
1410             return 1
1411         if apop:
1412             server.apop(user, password)
1413         else:
1414             server.user(user)
1415             server.pass_(password)
1416         numMessages = len(server.list()[1])
1417         for i in range(1, numMessages+1):
1418             # retr: returns
1419             # [ pop response e.g. '+OK 459 octets',
1420             #   [ array of message lines ],
1421             #   number of octets ]
1422             lines = server.retr(i)[1]
1423             s = cStringIO.StringIO('\n'.join(lines))
1424             s.seek(0)
1425             self.handle_Message(Message(s))
1426             # delete the message
1427             server.dele(i)
1429         # quit the server to commit changes.
1430         server.quit()
1431         return 0
1433     def main(self, fp):
1434         ''' fp - the file from which to read the Message.
1435         '''
1436         return self.handle_Message(Message(fp))
1438     def handle_Message(self, message):
1439         """Handle an RFC822 Message
1441         Handle the Message object by calling handle_message() and then cope
1442         with any errors raised by handle_message.
1443         This method's job is to make that call and handle any
1444         errors in a sane manner. It should be replaced if you wish to
1445         handle errors in a different manner.
1446         """
1447         # in some rare cases, a particularly stuffed-up e-mail will make
1448         # its way into here... try to handle it gracefully
1450         self.parsed_message = None
1451         sendto = message.getaddrlist('resent-from')
1452         if not sendto:
1453             sendto = message.getaddrlist('from')
1454         if not sendto:
1455             # very bad-looking message - we don't even know who sent it
1456             msg = ['Badly formed message from mail gateway. Headers:']
1457             msg.extend(message.headers)
1458             msg = '\n'.join(map(str, msg))
1459             self.logger.error(msg)
1460             return
1462         msg = 'Handling message'
1463         if message.getheader('message-id'):
1464             msg += ' (Message-id=%r)'%message.getheader('message-id')
1465         self.logger.info(msg)
1467         # try normal message-handling
1468         if not self.trapExceptions:
1469             return self.handle_message(message)
1471         # no, we want to trap exceptions
1472         # Note: by default we return the message received not the
1473         # internal state of the parsedMessage -- except for
1474         # MailUsageError, Unauthorized and for unknown exceptions. For
1475         # the latter cases we make sure the error message is encrypted
1476         # if needed (if it either was received encrypted or pgp
1477         # processing is turned on for the user).
1478         try:
1479             return self.handle_message(message)
1480         except MailUsageHelp:
1481             # bounce the message back to the sender with the usage message
1482             fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
1483             m = ['']
1484             m.append('\n\nMail Gateway Help\n=================')
1485             m.append(fulldoc)
1486             self.mailer.bounce_message(message, [sendto[0][1]], m,
1487                 subject="Mail Gateway Help")
1488         except MailUsageError, value:
1489             # bounce the message back to the sender with the usage message
1490             fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
1491             m = ['']
1492             m.append(str(value))
1493             m.append('\n\nMail Gateway Help\n=================')
1494             m.append(fulldoc)
1495             if self.parsed_message:
1496                 message = self.parsed_message.message
1497                 crypt = self.parsed_message.crypt
1498             self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt)
1499         except Unauthorized, value:
1500             # just inform the user that he is not authorized
1501             m = ['']
1502             m.append(str(value))
1503             if self.parsed_message:
1504                 message = self.parsed_message.message
1505                 crypt = self.parsed_message.crypt
1506             self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt)
1507         except IgnoreMessage:
1508             # do not take any action
1509             # this exception is thrown when email should be ignored
1510             msg = 'IgnoreMessage raised'
1511             if message.getheader('message-id'):
1512                 msg += ' (Message-id=%r)'%message.getheader('message-id')
1513             self.logger.info(msg)
1514             return
1515         except:
1516             msg = 'Exception handling message'
1517             if message.getheader('message-id'):
1518                 msg += ' (Message-id=%r)'%message.getheader('message-id')
1519             self.logger.exception(msg)
1521             # bounce the message back to the sender with the error message
1522             # let the admin know that something very bad is happening
1523             m = ['']
1524             m.append('An unexpected error occurred during the processing')
1525             m.append('of your message. The tracker administrator is being')
1526             m.append('notified.\n')
1527             if self.parsed_message:
1528                 message = self.parsed_message.message
1529                 crypt = self.parsed_message.crypt
1530             self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt)
1532             m.append('----------------')
1533             m.append(traceback.format_exc())
1534             self.mailer.bounce_message(message, [self.instance.config.ADMIN_EMAIL], m)
1536     def handle_message(self, message):
1537         ''' message - a Message instance
1539         Parse the message as per the module docstring.
1540         '''
1541         # get database handle for handling one email
1542         self.db = self.instance.open ('admin')
1543         try:
1544             return self._handle_message(message)
1545         finally:
1546             self.db.close()
1548     def _handle_message(self, message):
1549         ''' message - a Message instance
1551         Parse the message as per the module docstring.
1552         The following code expects an opened database and a try/finally
1553         that closes the database.
1554         '''
1555         self.parsed_message = self.parsed_message_class(self, message)
1556         nodeid = self.parsed_message.parse ()
1558         # commit the changes to the DB
1559         self.db.commit()
1561         self.parsed_message = None
1562         return nodeid
1564     def get_class_arguments(self, class_type, classname=None):
1565         ''' class_type - a valid node class type:
1566                 - 'user' refers to the author of a message
1567                 - 'issue' refers to an issue-type class (to which the
1568                   message is appended) specified in parameter classname
1569                   Note that this need not be the real classname, we get
1570                   the real classname used as a parameter (from previous
1571                   message-parsing steps)
1572                 - 'file' specifies a file-type class
1573                 - 'msg' is the message-class
1574             classname - the name of the current issue-type class
1576         Parse the commandline arguments and retrieve the properties that
1577         are relevant to the class_type. We now allow multiple -S options
1578         per class_type (-C option).
1579         '''
1580         allprops = {}
1582         classname = classname or class_type
1583         cls_lookup = { 'issue' : classname }
1584         
1585         # Allow other issue-type classes -- take the real classname from
1586         # previous parsing-steps of the message:
1587         clsname = cls_lookup.get (class_type, class_type)
1589         # check if the clsname is valid
1590         try:
1591             self.db.getclass(clsname)
1592         except KeyError:
1593             mailadmin = self.instance.config['ADMIN_EMAIL']
1594             raise MailUsageError, _("""
1595 The mail gateway is not properly set up. Please contact
1596 %(mailadmin)s and have them fix the incorrect class specified as:
1597   %(clsname)s
1598 """) % locals()
1599         
1600         if self.arguments:
1601             # The default type on the commandline is msg
1602             if class_type == 'msg':
1603                 current_type = class_type
1604             else:
1605                 current_type = None
1606             
1607             # Handle the arguments specified by the email gateway command line.
1608             # We do this by looping over the list of self.arguments looking for
1609             # a -C to match the class we want, then use the -S setting string.
1610             for option, propstring in self.arguments:
1611                 if option in ( '-C', '--class'):
1612                     current_type = propstring.strip()
1613                     
1614                     if current_type != class_type:
1615                         current_type = None
1617                 elif current_type and option in ('-S', '--set'):
1618                     cls = cls_lookup.get (current_type, current_type)
1619                     temp_cl = self.db.getclass(cls)
1620                     errors, props = setPropArrayFromString(self,
1621                         temp_cl, propstring.strip())
1623                     if errors:
1624                         mailadmin = self.instance.config['ADMIN_EMAIL']
1625                         raise MailUsageError, _("""
1626 The mail gateway is not properly set up. Please contact
1627 %(mailadmin)s and have them fix the incorrect properties:
1628   %(errors)s
1629 """) % locals()
1630                     allprops.update(props)
1632         return allprops
1635 def setPropArrayFromString(self, cl, propString, nodeid=None):
1636     ''' takes string of form prop=value,value;prop2=value
1637         and returns (error, prop[..])
1638     '''
1639     props = {}
1640     errors = []
1641     for prop in string.split(propString, ';'):
1642         # extract the property name and value
1643         try:
1644             propname, value = prop.split('=')
1645         except ValueError, message:
1646             errors.append(_('not of form [arg=value,value,...;'
1647                 'arg=value,value,...]'))
1648             return (errors, props)
1649         # convert the value to a hyperdb-usable value
1650         propname = propname.strip()
1651         try:
1652             props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1653                 propname, value)
1654         except hyperdb.HyperdbValueError, message:
1655             errors.append(str(message))
1656     return errors, props
1659 def extractUserFromList(userClass, users):
1660     '''Given a list of users, try to extract the first non-anonymous user
1661        and return that user, otherwise return None
1662     '''
1663     if len(users) > 1:
1664         for user in users:
1665             # make sure we don't match the anonymous or admin user
1666             if userClass.get(user, 'username') in ('admin', 'anonymous'):
1667                 continue
1668             # first valid match will do
1669             return user
1670         # well, I guess we have no choice
1671         return user[0]
1672     elif users:
1673         return users[0]
1674     return None
1677 def uidFromAddress(db, address, create=1, **user_props):
1678     ''' address is from the rfc822 module, and therefore is (name, addr)
1680         user is created if they don't exist in the db already
1681         user_props may supply additional user information
1682     '''
1683     (realname, address) = address
1685     # try a straight match of the address
1686     user = extractUserFromList(db.user, db.user.stringFind(address=address))
1687     if user is not None:
1688         return user
1690     # try the user alternate addresses if possible
1691     props = db.user.getprops()
1692     if props.has_key('alternate_addresses'):
1693         users = db.user.filter(None, {'alternate_addresses': address})
1694         # We want an exact match of the email, not just a substring
1695         # match. Otherwise e.g. support@example.com would match
1696         # discuss-support@example.com which is not what we want.
1697         found_users = []
1698         for u in users:
1699             alt = db.user.get(u, 'alternate_addresses').split('\n')
1700             for a in alt:
1701                 if a.strip().lower() == address.lower():
1702                     found_users.append(u)
1703                     break
1704         user = extractUserFromList(db.user, found_users)
1705         if user is not None:
1706             return user
1708     # try to match the username to the address (for local
1709     # submissions where the address is empty)
1710     user = extractUserFromList(db.user, db.user.stringFind(username=address))
1712     # couldn't match address or username, so create a new user
1713     if create:
1714         # generate a username
1715         if '@' in address:
1716             username = address.split('@')[0]
1717         else:
1718             username = address
1719         trying = username
1720         n = 0
1721         while 1:
1722             try:
1723                 # does this username exist already?
1724                 db.user.lookup(trying)
1725             except KeyError:
1726                 break
1727             n += 1
1728             trying = username + str(n)
1730         # create!
1731         try:
1732             return db.user.create(username=trying, address=address,
1733                 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1734                 password=password.Password(password.generatePassword(), config=db.config),
1735                 **user_props)
1736         except exceptions.Reject:
1737             return 0
1738     else:
1739         return 0
1741 def parseContent(content, keep_citations=None, keep_body=None, config=None):
1742     """Parse mail message; return message summary and stripped content
1744     The message body is divided into sections by blank lines.
1745     Sections where the second and all subsequent lines begin with a ">"
1746     or "|" character are considered "quoting sections". The first line of
1747     the first non-quoting section becomes the summary of the message.
1749     Arguments:
1751         keep_citations: declared for backward compatibility.
1752             If omitted or None, use config["MAILGW_KEEP_QUOTED_TEXT"]
1754         keep_body: declared for backward compatibility.
1755             If omitted or None, use config["MAILGW_LEAVE_BODY_UNCHANGED"]
1757         config: tracker configuration object.
1758             If omitted or None, use default configuration.
1760     """
1761     if config is None:
1762         config = configuration.CoreConfig()
1763     if keep_citations is None:
1764         keep_citations = config["MAILGW_KEEP_QUOTED_TEXT"]
1765     if keep_body is None:
1766         keep_body = config["MAILGW_LEAVE_BODY_UNCHANGED"]
1767     eol = config["MAILGW_EOL_RE"]
1768     signature = config["MAILGW_SIGN_RE"]
1769     original_msg = config["MAILGW_ORIGMSG_RE"]
1771     # strip off leading carriage-returns / newlines
1772     i = 0
1773     for i in range(len(content)):
1774         if content[i] not in '\r\n':
1775             break
1776     if i > 0:
1777         sections = config["MAILGW_BLANKLINE_RE"].split(content[i:])
1778     else:
1779         sections = config["MAILGW_BLANKLINE_RE"].split(content)
1781     # extract out the summary from the message
1782     summary = ''
1783     l = []
1784     for section in sections:
1785         #section = section.strip()
1786         if not section:
1787             continue
1788         lines = eol.split(section)
1789         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1790                 lines[1] and lines[1][0] in '>|'):
1791             # see if there's a response somewhere inside this section (ie.
1792             # no blank line between quoted message and response)
1793             for line in lines[1:]:
1794                 if line and line[0] not in '>|':
1795                     break
1796             else:
1797                 # we keep quoted bits if specified in the config
1798                 if keep_citations:
1799                     l.append(section)
1800                 continue
1801             # keep this section - it has reponse stuff in it
1802             lines = lines[lines.index(line):]
1803             section = '\n'.join(lines)
1804             # and while we're at it, use the first non-quoted bit as
1805             # our summary
1806             summary = section
1808         if not summary:
1809             # if we don't have our summary yet use the first line of this
1810             # section
1811             summary = section
1812         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1813             # lose any signature
1814             break
1815         elif original_msg.match(lines[0]):
1816             # ditch the stupid Outlook quoting of the entire original message
1817             break
1819         # and add the section to the output
1820         l.append(section)
1822     # figure the summary - find the first sentence-ending punctuation or the
1823     # first whole line, whichever is longest
1824     sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1825     if sentence:
1826         sentence = sentence.group(1)
1827     else:
1828         sentence = ''
1829     first = eol.split(summary)[0]
1830     summary = max(sentence, first)
1832     # Now reconstitute the message content minus the bits we don't care
1833     # about.
1834     if not keep_body:
1835         content = '\n\n'.join(l)
1837     return summary, content
1839 # vim: set filetype=python sts=4 sw=4 et si :