Code

80462780a91f6d88d6b231470d51bff2c5a08f05
[roundup.git] / roundup / mailgw.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17 #
19 __doc__ = '''
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.
31 Summary
32 -------
33 The "summary" property on message nodes is taken from the first non-quoting
34 section in the message body. The message body is divided into sections by
35 blank lines. Sections where the second and all subsequent lines begin with
36 a ">" or "|" character are considered "quoting sections". The first line of
37 the first non-quoting section becomes the summary of the message. 
39 Addresses
40 ---------
41 All of the addresses in the To: and Cc: headers of the incoming message are
42 looked up among the user nodes, and the corresponding users are placed in
43 the "recipients" property on the new "msg" node. The address in the From:
44 header similarly determines the "author" property of the new "msg"
45 node. The default handling for addresses that don't have corresponding
46 users is to create new users with no passwords and a username equal to the
47 address. (The web interface does not permit logins for users with no
48 passwords.) If we prefer to reject mail from outside sources, we can simply
49 register an auditor on the "user" class that prevents the creation of user
50 nodes with no passwords. 
52 Actions
53 -------
54 The subject line of the incoming message is examined to determine whether
55 the message is an attempt to create a new item or to discuss an existing
56 item. A designator enclosed in square brackets is sought as the first thing
57 on the subject line (after skipping any "Fwd:" or "Re:" prefixes). 
59 If an item designator (class name and id number) is found there, the newly
60 created "msg" node is added to the "messages" property for that item, and
61 any new "file" nodes are added to the "files" property for the item. 
63 If just an item class name is found there, we attempt to create a new item
64 of that class with its "messages" property initialized to contain the new
65 "msg" node and its "files" property initialized to contain any new "file"
66 nodes. 
68 Triggers
69 --------
70 Both cases may trigger detectors (in the first case we are calling the
71 set() method to add the message to the item's spool; in the second case we
72 are calling the create() method to create a new node). If an auditor raises
73 an exception, the original message is bounced back to the sender with the
74 explanatory message given in the exception. 
76 $Id: mailgw.py,v 1.83 2002-09-10 00:18:20 richard Exp $
77 '''
80 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
81 import time, random
82 import traceback, MimeWriter
83 import hyperdb, date, password
85 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
87 class MailGWError(ValueError):
88     pass
90 class MailUsageError(ValueError):
91     pass
93 class MailUsageHelp(Exception):
94     pass
96 class Unauthorized(Exception):
97     """ Access denied """
99 def initialiseSecurity(security):
100     ''' Create some Permissions and Roles on the security object
102         This function is directly invoked by security.Security.__init__()
103         as a part of the Security object instantiation.
104     '''
105     security.addPermission(name="Email Registration",
106         description="Anonymous may register through e-mail")
107     p = security.addPermission(name="Email Access",
108         description="User may use the email interface")
109     security.addPermissionToRole('Admin', p)
111 class Message(mimetools.Message):
112     ''' subclass mimetools.Message so we can retrieve the parts of the
113         message...
114     '''
115     def getPart(self):
116         ''' Get a single part of a multipart message and return it as a new
117             Message instance.
118         '''
119         boundary = self.getparam('boundary')
120         mid, end = '--'+boundary, '--'+boundary+'--'
121         s = cStringIO.StringIO()
122         while 1:
123             line = self.fp.readline()
124             if not line:
125                 break
126             if line.strip() in (mid, end):
127                 break
128             s.write(line)
129         if not s.getvalue().strip():
130             return None
131         s.seek(0)
132         return Message(s)
134 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re|aw)\s*\W?\s*)*'
135     r'\s*(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
136     r'\s*(?P<title>[^[]+)?(\[(?P<args>.+?)\])?', re.I)
138 class MailGW:
139     def __init__(self, instance, db):
140         self.instance = instance
141         self.db = db
143         # should we trap exceptions (normal usage) or pass them through
144         # (for testing)
145         self.trapExceptions = 1
147     def main(self, fp):
148         ''' fp - the file from which to read the Message.
149         '''
150         return self.handle_Message(Message(fp))
152     def handle_Message(self, message):
153         '''Handle an RFC822 Message
155         Handle the Message object by calling handle_message() and then cope
156         with any errors raised by handle_message.
157         This method's job is to make that call and handle any
158         errors in a sane manner. It should be replaced if you wish to
159         handle errors in a different manner.
160         '''
161         # in some rare cases, a particularly stuffed-up e-mail will make
162         # its way into here... try to handle it gracefully
163         sendto = message.getaddrlist('from')
164         if sendto:
165             if not self.trapExceptions:
166                 return self.handle_message(message)
167             try:
168                 return self.handle_message(message)
169             except MailUsageHelp:
170                 # bounce the message back to the sender with the usage message
171                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
172                 sendto = [sendto[0][1]]
173                 m = ['']
174                 m.append('\n\nMail Gateway Help\n=================')
175                 m.append(fulldoc)
176                 m = self.bounce_message(message, sendto, m,
177                     subject="Mail Gateway Help")
178             except MailUsageError, value:
179                 # bounce the message back to the sender with the usage message
180                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
181                 sendto = [sendto[0][1]]
182                 m = ['']
183                 m.append(str(value))
184                 m.append('\n\nMail Gateway Help\n=================')
185                 m.append(fulldoc)
186                 m = self.bounce_message(message, sendto, m)
187             except Unauthorized, value:
188                 # just inform the user that he is not authorized
189                 sendto = [sendto[0][1]]
190                 m = ['']
191                 m.append(str(value))
192                 m = self.bounce_message(message, sendto, m)
193             except:
194                 # bounce the message back to the sender with the error message
195                 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
196                 m = ['']
197                 m.append('An unexpected error occurred during the processing')
198                 m.append('of your message. The tracker administrator is being')
199                 m.append('notified.\n')
200                 m.append('----  traceback of failure  ----')
201                 s = cStringIO.StringIO()
202                 import traceback
203                 traceback.print_exc(None, s)
204                 m.append(s.getvalue())
205                 m = self.bounce_message(message, sendto, m)
206         else:
207             # very bad-looking message - we don't even know who sent it
208             sendto = [self.instance.config.ADMIN_EMAIL]
209             m = ['Subject: badly formed message from mail gateway']
210             m.append('')
211             m.append('The mail gateway retrieved a message which has no From:')
212             m.append('line, indicating that it is corrupt. Please check your')
213             m.append('mail gateway source. Failed message is attached.')
214             m.append('')
215             m = self.bounce_message(message, sendto, m,
216                 subject='Badly formed message from mail gateway')
218         # now send the message
219         if SENDMAILDEBUG:
220             open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
221                 self.instance.config.ADMIN_EMAIL, ', '.join(sendto),
222                     m.getvalue()))
223         else:
224             try:
225                 smtp = smtplib.SMTP(self.instance.config.MAILHOST)
226                 smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto,
227                     m.getvalue())
228             except socket.error, value:
229                 raise MailGWError, "Couldn't send error email: "\
230                     "mailhost %s"%value
231             except smtplib.SMTPException, value:
232                 raise MailGWError, "Couldn't send error email: %s"%value
234     def bounce_message(self, message, sendto, error,
235             subject='Failed issue tracker submission'):
236         ''' create a message that explains the reason for the failed
237             issue submission to the author and attach the original
238             message.
239         '''
240         msg = cStringIO.StringIO()
241         writer = MimeWriter.MimeWriter(msg)
242         writer.addheader('Subject', subject)
243         writer.addheader('From', '%s <%s>'% (self.instance.config.INSTANCE_NAME,
244             self.instance.config.ISSUE_TRACKER_EMAIL))
245         writer.addheader('To', ','.join(sendto))
246         writer.addheader('MIME-Version', '1.0')
247         part = writer.startmultipartbody('mixed')
248         part = writer.nextpart()
249         body = part.startbody('text/plain')
250         body.write('\n'.join(error))
252         # reconstruct the original message
253         m = cStringIO.StringIO()
254         w = MimeWriter.MimeWriter(m)
255         # default the content_type, just in case...
256         content_type = 'text/plain'
257         # add the headers except the content-type
258         for header in message.headers:
259             header_name = header.split(':')[0]
260             if header_name.lower() == 'content-type':
261                 content_type = message.getheader(header_name)
262             elif message.getheader(header_name):
263                 w.addheader(header_name, message.getheader(header_name))
264         # now attach the message body
265         body = w.startbody(content_type)
266         try:
267             message.rewindbody()
268         except IOError:
269             body.write("*** couldn't include message body: read from pipe ***")
270         else:
271             body.write(message.fp.read())
273         # attach the original message to the returned message
274         part = writer.nextpart()
275         part.addheader('Content-Disposition','attachment')
276         part.addheader('Content-Description','Message you sent')
277         part.addheader('Content-Transfer-Encoding', '7bit')
278         body = part.startbody('message/rfc822')
279         body.write(m.getvalue())
281         writer.lastpart()
282         return msg
284     def get_part_data_decoded(self,part):
285         encoding = part.getencoding()
286         data = None
287         if encoding == 'base64':
288             # BUG: is base64 really used for text encoding or
289             # are we inserting zip files here. 
290             data = binascii.a2b_base64(part.fp.read())
291         elif encoding == 'quoted-printable':
292             # the quopri module wants to work with files
293             decoded = cStringIO.StringIO()
294             quopri.decode(part.fp, decoded)
295             data = decoded.getvalue()
296         elif encoding == 'uuencoded':
297             data = binascii.a2b_uu(part.fp.read())
298         else:
299             # take it as text
300             data = part.fp.read()
301         return data
303     def handle_message(self, message):
304         ''' message - a Message instance
306         Parse the message as per the module docstring.
307         '''
308         # handle the subject line
309         subject = message.getheader('subject', '')
311         if subject.strip() == 'help':
312             raise MailUsageHelp
314         m = subject_re.match(subject)
316         # check for well-formed subject line
317         if m:
318             # get the classname
319             classname = m.group('classname')
320             if classname is None:
321                 # no classname, fallback on the default
322                 if hasattr(self.instance, 'MAIL_DEFAULT_CLASS') and \
323                         self.instance.config.MAIL_DEFAULT_CLASS:
324                     classname = self.instance.config.MAIL_DEFAULT_CLASS
325                 else:
326                     # fail
327                     m = None
329         if not m:
330             raise MailUsageError, '''
331 The message you sent to roundup did not contain a properly formed subject
332 line. The subject must contain a class name or designator to indicate the
333 "topic" of the message. For example:
334     Subject: [issue] This is a new issue
335       - this will create a new issue in the tracker with the title "This is
336         a new issue".
337     Subject: [issue1234] This is a followup to issue 1234
338       - this will append the message's contents to the existing issue 1234
339         in the tracker.
341 Subject was: "%s"
342 '''%subject
344         # get the class
345         try:
346             cl = self.db.getclass(classname)
347         except KeyError:
348             raise MailUsageError, '''
349 The class name you identified in the subject line ("%s") does not exist in the
350 database.
352 Valid class names are: %s
353 Subject was: "%s"
354 '''%(classname, ', '.join(self.db.getclasses()), subject)
356         # get the optional nodeid
357         nodeid = m.group('nodeid')
359         # title is optional too
360         title = m.group('title')
361         if title:
362             title = title.strip()
363         else:
364             title = ''
366         # but we do need either a title or a nodeid...
367         if nodeid is None and not title:
368             raise MailUsageError, '''
369 I cannot match your message to a node in the database - you need to either
370 supply a full node identifier (with number, eg "[issue123]" or keep the
371 previous subject title intact so I can match that.
373 Subject was: "%s"
374 '''%subject
376         # If there's no nodeid, check to see if this is a followup and
377         # maybe someone's responded to the initial mail that created an
378         # entry. Try to find the matching nodes with the same title, and
379         # use the _last_ one matched (since that'll _usually_ be the most
380         # recent...)
381         if nodeid is None and m.group('refwd'):
382             l = cl.stringFind(title=title)
383             if l:
384                 nodeid = l[-1]
386         # if a nodeid was specified, make sure it's valid
387         if nodeid is not None and not cl.hasnode(nodeid):
388             raise MailUsageError, '''
389 The node specified by the designator in the subject of your message ("%s")
390 does not exist.
392 Subject was: "%s"
393 '''%(nodeid, subject)
395         #
396         # extract the args
397         #
398         subject_args = m.group('args')
400         #
401         # handle the subject argument list
402         #
403         # figure what the properties of this Class are
404         properties = cl.getprops()
405         props = {}
406         args = m.group('args')
407         if args:
408             errors = []
409             for prop in string.split(args, ';'):
410                 # extract the property name and value
411                 try:
412                     propname, value = prop.split('=')
413                 except ValueError, message:
414                     errors.append('not of form [arg=value,'
415                         'value,...;arg=value,value...]')
416                     break
418                 # ensure it's a valid property name
419                 propname = propname.strip()
420                 try:
421                     proptype =  properties[propname]
422                 except KeyError:
423                     errors.append('refers to an invalid property: '
424                         '"%s"'%propname)
425                     continue
427                 # convert the string value to a real property value
428                 if isinstance(proptype, hyperdb.String):
429                     props[propname] = value.strip()
430                 if isinstance(proptype, hyperdb.Password):
431                     props[propname] = password.Password(value.strip())
432                 elif isinstance(proptype, hyperdb.Date):
433                     try:
434                         props[propname] = date.Date(value.strip())
435                     except ValueError, message:
436                         errors.append('contains an invalid date for '
437                             '%s.'%propname)
438                 elif isinstance(proptype, hyperdb.Interval):
439                     try:
440                         props[propname] = date.Interval(value)
441                     except ValueError, message:
442                         errors.append('contains an invalid date interval'
443                             'for %s.'%propname)
444                 elif isinstance(proptype, hyperdb.Link):
445                     linkcl = self.db.classes[proptype.classname]
446                     propkey = linkcl.labelprop(default_to_id=1)
447                     try:
448                         props[propname] = linkcl.lookup(value)
449                     except KeyError, message:
450                         errors.append('"%s" is not a value for %s.'%(value,
451                             propname))
452                 elif isinstance(proptype, hyperdb.Multilink):
453                     # get the linked class
454                     linkcl = self.db.classes[proptype.classname]
455                     propkey = linkcl.labelprop(default_to_id=1)
456                     if nodeid:
457                         curvalue = cl.get(nodeid, propname)
458                     else:
459                         curvalue = []
461                     # handle each add/remove in turn
462                     # keep an extra list for all items that are
463                     # definitely in the new list (in case of e.g.
464                     # <propname>=A,+B, which should replace the old
465                     # list with A,B)
466                     set = 0
467                     newvalue = []
468                     for item in value.split(','):
469                         item = item.strip()
471                         # handle +/-
472                         remove = 0
473                         if item.startswith('-'):
474                             remove = 1
475                             item = item[1:]
476                         elif item.startswith('+'):
477                             item = item[1:]
478                         else:
479                             set = 1
481                         # look up the value
482                         try:
483                             item = linkcl.lookup(item)
484                         except KeyError, message:
485                             errors.append('"%s" is not a value for %s.'%(item,
486                                 propname))
487                             continue
489                         # perform the add/remove
490                         if remove:
491                             try:
492                                 curvalue.remove(item)
493                             except ValueError:
494                                 errors.append('"%s" is not currently in '
495                                     'for %s.'%(item, propname))
496                                 continue
497                         else:
498                             newvalue.append(item)
499                             if item not in curvalue:
500                                 curvalue.append(item)
502                     # that's it, set the new Multilink property value,
503                     # or overwrite it completely
504                     if set:
505                         props[propname] = newvalue
506                     else:
507                         props[propname] = curvalue
508                 elif isinstance(proptype, hyperdb.Boolean):
509                     value = value.strip()
510                     props[propname] = value.lower() in ('yes', 'true', 'on', '1')
511                 elif isinstance(proptype, hyperdb.Number):
512                     value = value.strip()
513                     props[propname] = int(value)
515             # handle any errors parsing the argument list
516             if errors:
517                 errors = '\n- '.join(errors)
518                 raise MailUsageError, '''
519 There were problems handling your subject line argument list:
520 - %s
522 Subject was: "%s"
523 '''%(errors, subject)
525         #
526         # handle the users
527         #
529         # Don't create users if anonymous isn't allowed to register
530         create = 1
531         anonid = self.db.user.lookup('anonymous')
532         if not self.db.security.hasPermission('Email Registration', anonid):
533             create = 0
535         # ok, now figure out who the author is - create a new user if the
536         # "create" flag is true
537         author = uidFromAddress(self.db, message.getaddrlist('from')[0],
538             create=create)
540         # no author? means we're not author
541         if not author:
542             raise Unauthorized, '''
543 You are not a registered user.
545 Unknown address: %s
546 '''%message.getaddrlist('from')[0][1]
548         # make sure the author has permission to use the email interface
549         if not self.db.security.hasPermission('Email Access', author):
550             raise Unauthorized, 'You are not permitted to access this tracker.'
552         # the author may have been created - make sure the change is
553         # committed before we reopen the database
554         self.db.commit()
555             
556         # reopen the database as the author
557         username = self.db.user.get(author, 'username')
558         self.db = self.instance.open(username)
560         # re-get the class with the new database connection
561         cl = self.db.getclass(classname)
563         # now update the recipients list
564         recipients = []
565         tracker_email = self.instance.config.ISSUE_TRACKER_EMAIL.lower()
566         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
567             r = recipient[1].strip().lower()
568             if r == tracker_email or not r:
569                 continue
571             # look up the recipient - create if necessary (and we're
572             # allowed to)
573             recipient = uidFromAddress(self.db, recipient, create)
575             # if all's well, add the recipient to the list
576             if recipient:
577                 recipients.append(recipient)
579         #
580         # handle message-id and in-reply-to
581         #
582         messageid = message.getheader('message-id')
583         inreplyto = message.getheader('in-reply-to') or ''
584         # generate a messageid if there isn't one
585         if not messageid:
586             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
587                 classname, nodeid, self.instance.config.MAIL_DOMAIN)
589         #
590         # now handle the body - find the message
591         #
592         content_type =  message.gettype()
593         attachments = []
594         # General multipart handling:
595         #   Take the first text/plain part, anything else is considered an 
596         #   attachment.
597         # multipart/mixed: multiple "unrelated" parts.
598         # multipart/signed (rfc 1847): 
599         #   The control information is carried in the second of the two 
600         #   required body parts.
601         #   ACTION: Default, so if content is text/plain we get it.
602         # multipart/encrypted (rfc 1847): 
603         #   The control information is carried in the first of the two 
604         #   required body parts.
605         #   ACTION: Not handleable as the content is encrypted.
606         # multipart/related (rfc 1872, 2112, 2387):
607         #   The Multipart/Related content-type addresses the MIME
608         #   representation of compound objects.
609         #   ACTION: Default. If we are lucky there is a text/plain.
610         #   TODO: One should use the start part and look for an Alternative
611         #   that is text/plain.
612         # multipart/Alternative (rfc 1872, 1892):
613         #   only in "related" ?
614         # multipart/report (rfc 1892):
615         #   e.g. mail system delivery status reports.
616         #   ACTION: Default. Could be ignored or used for Delivery Notification 
617         #   flagging.
618         # multipart/form-data:
619         #   For web forms only.
620         if content_type == 'multipart/mixed':
621             # skip over the intro to the first boundary
622             part = message.getPart()
623             content = None
624             while 1:
625                 # get the next part
626                 part = message.getPart()
627                 if part is None:
628                     break
629                 # parse it
630                 subtype = part.gettype()
631                 if subtype == 'text/plain' and not content:
632                     # The first text/plain part is the message content.
633                     content = self.get_part_data_decoded(part) 
634                 elif subtype == 'message/rfc822':
635                     # handle message/rfc822 specially - the name should be
636                     # the subject of the actual e-mail embedded here
637                     i = part.fp.tell()
638                     mailmess = Message(part.fp)
639                     name = mailmess.getheader('subject')
640                     part.fp.seek(i)
641                     attachments.append((name, 'message/rfc822', part.fp.read()))
642                 else:
643                     # try name on Content-Type
644                     name = part.getparam('name')
645                     # this is just an attachment
646                     data = self.get_part_data_decoded(part) 
647                     attachments.append((name, part.gettype(), data))
648             if content is None:
649                 raise MailUsageError, '''
650 Roundup requires the submission to be plain text. The message parser could
651 not find a text/plain part to use.
652 '''
654         elif content_type[:10] == 'multipart/':
655             # skip over the intro to the first boundary
656             message.getPart()
657             content = None
658             while 1:
659                 # get the next part
660                 part = message.getPart()
661                 if part is None:
662                     break
663                 # parse it
664                 if part.gettype() == 'text/plain' and not content:
665                     content = self.get_part_data_decoded(part) 
666             if content is None:
667                 raise MailUsageError, '''
668 Roundup requires the submission to be plain text. The message parser could
669 not find a text/plain part to use.
670 '''
672         elif content_type != 'text/plain':
673             raise MailUsageError, '''
674 Roundup requires the submission to be plain text. The message parser could
675 not find a text/plain part to use.
676 '''
678         else:
679             content = self.get_part_data_decoded(message) 
680  
681         # figure how much we should muck around with the email body
682         keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT',
683             'no') == 'yes'
684         keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED',
685             'no') == 'yes'
687         # parse the body of the message, stripping out bits as appropriate
688         summary, content = parseContent(content, keep_citations, 
689             keep_body)
691         # 
692         # handle the attachments
693         #
694         files = []
695         for (name, mime_type, data) in attachments:
696             if not name:
697                 name = "unnamed"
698             files.append(self.db.file.create(type=mime_type, name=name,
699                 content=data))
701         # 
702         # create the message if there's a message body (content)
703         #
704         if content:
705             message_id = self.db.msg.create(author=author,
706                 recipients=recipients, date=date.Date('.'), summary=summary,
707                 content=content, files=files, messageid=messageid,
708                 inreplyto=inreplyto)
710             # attach the message to the node
711             if nodeid:
712                 # add the message to the node's list
713                 messages = cl.get(nodeid, 'messages')
714                 messages.append(message_id)
715                 props['messages'] = messages
716             else:
717                 # pre-load the messages list
718                 props['messages'] = [message_id]
720                 # set the title to the subject
721                 if properties.has_key('title') and not props.has_key('title'):
722                     props['title'] = title
724         #
725         # perform the node change / create
726         #
727         try:
728             if nodeid:
729                 cl.set(nodeid, **props)
730             else:
731                 nodeid = cl.create(**props)
732         except (TypeError, IndexError, ValueError), message:
733             raise MailUsageError, '''
734 There was a problem with the message you sent:
735    %s
736 '''%message
738         # commit the changes to the DB
739         self.db.commit()
741         return nodeid
743 def extractUserFromList(userClass, users):
744     '''Given a list of users, try to extract the first non-anonymous user
745        and return that user, otherwise return None
746     '''
747     if len(users) > 1:
748         for user in users:
749             # make sure we don't match the anonymous or admin user
750             if userClass.get(user, 'username') in ('admin', 'anonymous'):
751                 continue
752             # first valid match will do
753             return user
754         # well, I guess we have no choice
755         return user[0]
756     elif users:
757         return users[0]
758     return None
760 def uidFromAddress(db, address, create=1):
761     ''' address is from the rfc822 module, and therefore is (name, addr)
763         user is created if they don't exist in the db already
764     '''
765     (realname, address) = address
767     # try a straight match of the address
768     user = extractUserFromList(db.user, db.user.stringFind(address=address))
769     if user is not None: return user
771     # try the user alternate addresses if possible
772     props = db.user.getprops()
773     if props.has_key('alternate_addresses'):
774         users = db.user.filter(None, {'alternate_addresses': address},
775             [], [])
776         user = extractUserFromList(db.user, users)
777         if user is not None: return user
779     # try to match the username to the address (for local
780     # submissions where the address is empty)
781     user = extractUserFromList(db.user, db.user.stringFind(username=address))
783     # couldn't match address or username, so create a new user
784     if create:
785         return db.user.create(username=address, address=address,
786             realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES)
787     else:
788         return 0
790 def parseContent(content, keep_citations, keep_body,
791         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
792         eol=re.compile(r'[\r\n]+'), 
793         signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
794         original_message=re.compile(r'^[>|\s]*-----Original Message-----$')):
795     ''' The message body is divided into sections by blank lines.
796     Sections where the second and all subsequent lines begin with a ">" or "|"
797     character are considered "quoting sections". The first line of the first
798     non-quoting section becomes the summary of the message. 
799     '''
800     # strip off leading carriage-returns / newlines
801     i = 0
802     for i in range(len(content)):
803         if content[i] not in '\r\n':
804             break
805     if i > 0:
806         sections = blank_line.split(content[i:])
807     else:
808         sections = blank_line.split(content)
810     # extract out the summary from the message
811     summary = ''
812     l = []
813     for section in sections:
814         #section = section.strip()
815         if not section:
816             continue
817         lines = eol.split(section)
818         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
819                 lines[1] and lines[1][0] in '>|'):
820             # see if there's a response somewhere inside this section (ie.
821             # no blank line between quoted message and response)
822             for line in lines[1:]:
823                 if line[0] not in '>|':
824                     break
825             else:
826                 # we keep quoted bits if specified in the config
827                 if keep_citations:
828                     l.append(section)
829                 continue
830             # keep this section - it has reponse stuff in it
831             if not summary:
832                 # and while we're at it, use the first non-quoted bit as
833                 # our summary
834                 summary = line
835             lines = lines[lines.index(line):]
836             section = '\n'.join(lines)
838         if not summary:
839             # if we don't have our summary yet use the first line of this
840             # section
841             summary = lines[0]
842         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
843             # lose any signature
844             break
845         elif original_message.match(lines[0]):
846             # ditch the stupid Outlook quoting of the entire original message
847             break
849         # and add the section to the output
850         l.append(section)
851     # we only set content for those who want to delete cruft from the
852     # message body, otherwise the body is left untouched.
853     if not keep_body:
854         content = '\n\n'.join(l)
855     return summary, content
857 # vim: set filetype=python ts=4 sw=4 et si