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.84 2002-09-10 02:37:27 richard Exp $
77 '''
79 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
80 import time, random
81 import traceback, MimeWriter
82 import hyperdb, date, password
84 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
86 class MailGWError(ValueError):
87 pass
89 class MailUsageError(ValueError):
90 pass
92 class MailUsageHelp(Exception):
93 pass
95 class Unauthorized(Exception):
96 """ Access denied """
98 def initialiseSecurity(security):
99 ''' Create some Permissions and Roles on the security object
101 This function is directly invoked by security.Security.__init__()
102 as a part of the Security object instantiation.
103 '''
104 security.addPermission(name="Email Registration",
105 description="Anonymous may register through e-mail")
106 p = security.addPermission(name="Email Access",
107 description="User may use the email interface")
108 security.addPermissionToRole('Admin', p)
110 class Message(mimetools.Message):
111 ''' subclass mimetools.Message so we can retrieve the parts of the
112 message...
113 '''
114 def getPart(self):
115 ''' Get a single part of a multipart message and return it as a new
116 Message instance.
117 '''
118 boundary = self.getparam('boundary')
119 mid, end = '--'+boundary, '--'+boundary+'--'
120 s = cStringIO.StringIO()
121 while 1:
122 line = self.fp.readline()
123 if not line:
124 break
125 if line.strip() in (mid, end):
126 break
127 s.write(line)
128 if not s.getvalue().strip():
129 return None
130 s.seek(0)
131 return Message(s)
133 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re|aw)\s*\W?\s*)*'
134 r'\s*(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
135 r'\s*(?P<title>[^[]+)?(\[(?P<args>.+?)\])?', re.I)
137 class MailGW:
138 def __init__(self, instance, db):
139 self.instance = instance
140 self.db = db
142 # should we trap exceptions (normal usage) or pass them through
143 # (for testing)
144 self.trapExceptions = 1
146 def main(self, fp):
147 ''' fp - the file from which to read the Message.
148 '''
149 return self.handle_Message(Message(fp))
151 def handle_Message(self, message):
152 '''Handle an RFC822 Message
154 Handle the Message object by calling handle_message() and then cope
155 with any errors raised by handle_message.
156 This method's job is to make that call and handle any
157 errors in a sane manner. It should be replaced if you wish to
158 handle errors in a different manner.
159 '''
160 # in some rare cases, a particularly stuffed-up e-mail will make
161 # its way into here... try to handle it gracefully
162 sendto = message.getaddrlist('from')
163 if sendto:
164 if not self.trapExceptions:
165 return self.handle_message(message)
166 try:
167 return self.handle_message(message)
168 except MailUsageHelp:
169 # bounce the message back to the sender with the usage message
170 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
171 sendto = [sendto[0][1]]
172 m = ['']
173 m.append('\n\nMail Gateway Help\n=================')
174 m.append(fulldoc)
175 m = self.bounce_message(message, sendto, m,
176 subject="Mail Gateway Help")
177 except MailUsageError, value:
178 # bounce the message back to the sender with the usage message
179 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
180 sendto = [sendto[0][1]]
181 m = ['']
182 m.append(str(value))
183 m.append('\n\nMail Gateway Help\n=================')
184 m.append(fulldoc)
185 m = self.bounce_message(message, sendto, m)
186 except Unauthorized, value:
187 # just inform the user that he is not authorized
188 sendto = [sendto[0][1]]
189 m = ['']
190 m.append(str(value))
191 m = self.bounce_message(message, sendto, m)
192 except:
193 # bounce the message back to the sender with the error message
194 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
195 m = ['']
196 m.append('An unexpected error occurred during the processing')
197 m.append('of your message. The tracker administrator is being')
198 m.append('notified.\n')
199 m.append('---- traceback of failure ----')
200 s = cStringIO.StringIO()
201 import traceback
202 traceback.print_exc(None, s)
203 m.append(s.getvalue())
204 m = self.bounce_message(message, sendto, m)
205 else:
206 # very bad-looking message - we don't even know who sent it
207 sendto = [self.instance.config.ADMIN_EMAIL]
208 m = ['Subject: badly formed message from mail gateway']
209 m.append('')
210 m.append('The mail gateway retrieved a message which has no From:')
211 m.append('line, indicating that it is corrupt. Please check your')
212 m.append('mail gateway source. Failed message is attached.')
213 m.append('')
214 m = self.bounce_message(message, sendto, m,
215 subject='Badly formed message from mail gateway')
217 # now send the message
218 if SENDMAILDEBUG:
219 open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
220 self.instance.config.ADMIN_EMAIL, ', '.join(sendto),
221 m.getvalue()))
222 else:
223 try:
224 smtp = smtplib.SMTP(self.instance.config.MAILHOST)
225 smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto,
226 m.getvalue())
227 except socket.error, value:
228 raise MailGWError, "Couldn't send error email: "\
229 "mailhost %s"%value
230 except smtplib.SMTPException, value:
231 raise MailGWError, "Couldn't send error email: %s"%value
233 def bounce_message(self, message, sendto, error,
234 subject='Failed issue tracker submission'):
235 ''' create a message that explains the reason for the failed
236 issue submission to the author and attach the original
237 message.
238 '''
239 msg = cStringIO.StringIO()
240 writer = MimeWriter.MimeWriter(msg)
241 writer.addheader('Subject', subject)
242 writer.addheader('From', '%s <%s>'% (self.instance.config.INSTANCE_NAME,
243 self.instance.config.ISSUE_TRACKER_EMAIL))
244 writer.addheader('To', ','.join(sendto))
245 writer.addheader('MIME-Version', '1.0')
246 part = writer.startmultipartbody('mixed')
247 part = writer.nextpart()
248 body = part.startbody('text/plain')
249 body.write('\n'.join(error))
251 # reconstruct the original message
252 m = cStringIO.StringIO()
253 w = MimeWriter.MimeWriter(m)
254 # default the content_type, just in case...
255 content_type = 'text/plain'
256 # add the headers except the content-type
257 for header in message.headers:
258 header_name = header.split(':')[0]
259 if header_name.lower() == 'content-type':
260 content_type = message.getheader(header_name)
261 elif message.getheader(header_name):
262 w.addheader(header_name, message.getheader(header_name))
263 # now attach the message body
264 body = w.startbody(content_type)
265 try:
266 message.rewindbody()
267 except IOError:
268 body.write("*** couldn't include message body: read from pipe ***")
269 else:
270 body.write(message.fp.read())
272 # attach the original message to the returned message
273 part = writer.nextpart()
274 part.addheader('Content-Disposition','attachment')
275 part.addheader('Content-Description','Message you sent')
276 part.addheader('Content-Transfer-Encoding', '7bit')
277 body = part.startbody('message/rfc822')
278 body.write(m.getvalue())
280 writer.lastpart()
281 return msg
283 def get_part_data_decoded(self,part):
284 encoding = part.getencoding()
285 data = None
286 if encoding == 'base64':
287 # BUG: is base64 really used for text encoding or
288 # are we inserting zip files here.
289 data = binascii.a2b_base64(part.fp.read())
290 elif encoding == 'quoted-printable':
291 # the quopri module wants to work with files
292 decoded = cStringIO.StringIO()
293 quopri.decode(part.fp, decoded)
294 data = decoded.getvalue()
295 elif encoding == 'uuencoded':
296 data = binascii.a2b_uu(part.fp.read())
297 else:
298 # take it as text
299 data = part.fp.read()
300 return data
302 def handle_message(self, message):
303 ''' message - a Message instance
305 Parse the message as per the module docstring.
306 '''
307 # handle the subject line
308 subject = message.getheader('subject', '')
310 if subject.strip() == 'help':
311 raise MailUsageHelp
313 m = subject_re.match(subject)
315 # check for well-formed subject line
316 if m:
317 # get the classname
318 classname = m.group('classname')
319 if classname is None:
320 # no classname, fallback on the default
321 if hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
322 self.instance.config.MAIL_DEFAULT_CLASS:
323 classname = self.instance.config.MAIL_DEFAULT_CLASS
324 else:
325 # fail
326 m = None
328 if not m:
329 raise MailUsageError, '''
330 The message you sent to roundup did not contain a properly formed subject
331 line. The subject must contain a class name or designator to indicate the
332 "topic" of the message. For example:
333 Subject: [issue] This is a new issue
334 - this will create a new issue in the tracker with the title "This is
335 a new issue".
336 Subject: [issue1234] This is a followup to issue 1234
337 - this will append the message's contents to the existing issue 1234
338 in the tracker.
340 Subject was: "%s"
341 '''%subject
343 # get the class
344 try:
345 cl = self.db.getclass(classname)
346 except KeyError:
347 raise MailUsageError, '''
348 The class name you identified in the subject line ("%s") does not exist in the
349 database.
351 Valid class names are: %s
352 Subject was: "%s"
353 '''%(classname, ', '.join(self.db.getclasses()), subject)
355 # get the optional nodeid
356 nodeid = m.group('nodeid')
358 # title is optional too
359 title = m.group('title')
360 if title:
361 title = title.strip()
362 else:
363 title = ''
365 # but we do need either a title or a nodeid...
366 if nodeid is None and not title:
367 raise MailUsageError, '''
368 I cannot match your message to a node in the database - you need to either
369 supply a full node identifier (with number, eg "[issue123]" or keep the
370 previous subject title intact so I can match that.
372 Subject was: "%s"
373 '''%subject
375 # If there's no nodeid, check to see if this is a followup and
376 # maybe someone's responded to the initial mail that created an
377 # entry. Try to find the matching nodes with the same title, and
378 # use the _last_ one matched (since that'll _usually_ be the most
379 # recent...)
380 if nodeid is None and m.group('refwd'):
381 l = cl.stringFind(title=title)
382 if l:
383 nodeid = l[-1]
385 # if a nodeid was specified, make sure it's valid
386 if nodeid is not None and not cl.hasnode(nodeid):
387 raise MailUsageError, '''
388 The node specified by the designator in the subject of your message ("%s")
389 does not exist.
391 Subject was: "%s"
392 '''%(nodeid, subject)
394 #
395 # extract the args
396 #
397 subject_args = m.group('args')
399 #
400 # handle the subject argument list
401 #
402 # figure what the properties of this Class are
403 properties = cl.getprops()
404 props = {}
405 args = m.group('args')
406 if args:
407 errors = []
408 for prop in string.split(args, ';'):
409 # extract the property name and value
410 try:
411 propname, value = prop.split('=')
412 except ValueError, message:
413 errors.append('not of form [arg=value,'
414 'value,...;arg=value,value...]')
415 break
417 # ensure it's a valid property name
418 propname = propname.strip()
419 try:
420 proptype = properties[propname]
421 except KeyError:
422 errors.append('refers to an invalid property: '
423 '"%s"'%propname)
424 continue
426 # convert the string value to a real property value
427 if isinstance(proptype, hyperdb.String):
428 props[propname] = value.strip()
429 if isinstance(proptype, hyperdb.Password):
430 props[propname] = password.Password(value.strip())
431 elif isinstance(proptype, hyperdb.Date):
432 try:
433 props[propname] = date.Date(value.strip())
434 except ValueError, message:
435 errors.append('contains an invalid date for '
436 '%s.'%propname)
437 elif isinstance(proptype, hyperdb.Interval):
438 try:
439 props[propname] = date.Interval(value)
440 except ValueError, message:
441 errors.append('contains an invalid date interval'
442 'for %s.'%propname)
443 elif isinstance(proptype, hyperdb.Link):
444 linkcl = self.db.classes[proptype.classname]
445 propkey = linkcl.labelprop(default_to_id=1)
446 try:
447 props[propname] = linkcl.lookup(value)
448 except KeyError, message:
449 errors.append('"%s" is not a value for %s.'%(value,
450 propname))
451 elif isinstance(proptype, hyperdb.Multilink):
452 # get the linked class
453 linkcl = self.db.classes[proptype.classname]
454 propkey = linkcl.labelprop(default_to_id=1)
455 if nodeid:
456 curvalue = cl.get(nodeid, propname)
457 else:
458 curvalue = []
460 # handle each add/remove in turn
461 # keep an extra list for all items that are
462 # definitely in the new list (in case of e.g.
463 # <propname>=A,+B, which should replace the old
464 # list with A,B)
465 set = 0
466 newvalue = []
467 for item in value.split(','):
468 item = item.strip()
470 # handle +/-
471 remove = 0
472 if item.startswith('-'):
473 remove = 1
474 item = item[1:]
475 elif item.startswith('+'):
476 item = item[1:]
477 else:
478 set = 1
480 # look up the value
481 try:
482 item = linkcl.lookup(item)
483 except KeyError, message:
484 errors.append('"%s" is not a value for %s.'%(item,
485 propname))
486 continue
488 # perform the add/remove
489 if remove:
490 try:
491 curvalue.remove(item)
492 except ValueError:
493 errors.append('"%s" is not currently in '
494 'for %s.'%(item, propname))
495 continue
496 else:
497 newvalue.append(item)
498 if item not in curvalue:
499 curvalue.append(item)
501 # that's it, set the new Multilink property value,
502 # or overwrite it completely
503 if set:
504 props[propname] = newvalue
505 else:
506 props[propname] = curvalue
507 elif isinstance(proptype, hyperdb.Boolean):
508 value = value.strip()
509 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
510 elif isinstance(proptype, hyperdb.Number):
511 value = value.strip()
512 props[propname] = int(value)
514 # handle any errors parsing the argument list
515 if errors:
516 errors = '\n- '.join(errors)
517 raise MailUsageError, '''
518 There were problems handling your subject line argument list:
519 - %s
521 Subject was: "%s"
522 '''%(errors, subject)
524 #
525 # handle the users
526 #
528 # Don't create users if anonymous isn't allowed to register
529 create = 1
530 anonid = self.db.user.lookup('anonymous')
531 if not self.db.security.hasPermission('Email Registration', anonid):
532 create = 0
534 # ok, now figure out who the author is - create a new user if the
535 # "create" flag is true
536 author = uidFromAddress(self.db, message.getaddrlist('from')[0],
537 create=create)
539 # no author? means we're not author
540 if not author:
541 raise Unauthorized, '''
542 You are not a registered user.
544 Unknown address: %s
545 '''%message.getaddrlist('from')[0][1]
547 # make sure the author has permission to use the email interface
548 if not self.db.security.hasPermission('Email Access', author):
549 raise Unauthorized, 'You are not permitted to access this tracker.'
551 # the author may have been created - make sure the change is
552 # committed before we reopen the database
553 self.db.commit()
555 # reopen the database as the author
556 username = self.db.user.get(author, 'username')
557 self.db = self.instance.open(username)
559 # re-get the class with the new database connection
560 cl = self.db.getclass(classname)
562 # now update the recipients list
563 recipients = []
564 tracker_email = self.instance.config.ISSUE_TRACKER_EMAIL.lower()
565 for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
566 r = recipient[1].strip().lower()
567 if r == tracker_email or not r:
568 continue
570 # look up the recipient - create if necessary (and we're
571 # allowed to)
572 recipient = uidFromAddress(self.db, recipient, create)
574 # if all's well, add the recipient to the list
575 if recipient:
576 recipients.append(recipient)
578 #
579 # handle message-id and in-reply-to
580 #
581 messageid = message.getheader('message-id')
582 inreplyto = message.getheader('in-reply-to') or ''
583 # generate a messageid if there isn't one
584 if not messageid:
585 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
586 classname, nodeid, self.instance.config.MAIL_DOMAIN)
588 #
589 # now handle the body - find the message
590 #
591 content_type = message.gettype()
592 attachments = []
593 # General multipart handling:
594 # Take the first text/plain part, anything else is considered an
595 # attachment.
596 # multipart/mixed: multiple "unrelated" parts.
597 # multipart/signed (rfc 1847):
598 # The control information is carried in the second of the two
599 # required body parts.
600 # ACTION: Default, so if content is text/plain we get it.
601 # multipart/encrypted (rfc 1847):
602 # The control information is carried in the first of the two
603 # required body parts.
604 # ACTION: Not handleable as the content is encrypted.
605 # multipart/related (rfc 1872, 2112, 2387):
606 # The Multipart/Related content-type addresses the MIME
607 # representation of compound objects.
608 # ACTION: Default. If we are lucky there is a text/plain.
609 # TODO: One should use the start part and look for an Alternative
610 # that is text/plain.
611 # multipart/Alternative (rfc 1872, 1892):
612 # only in "related" ?
613 # multipart/report (rfc 1892):
614 # e.g. mail system delivery status reports.
615 # ACTION: Default. Could be ignored or used for Delivery Notification
616 # flagging.
617 # multipart/form-data:
618 # For web forms only.
619 if content_type == 'multipart/mixed':
620 # skip over the intro to the first boundary
621 part = message.getPart()
622 content = None
623 while 1:
624 # get the next part
625 part = message.getPart()
626 if part is None:
627 break
628 # parse it
629 subtype = part.gettype()
630 if subtype == 'text/plain' and not content:
631 # The first text/plain part is the message content.
632 content = self.get_part_data_decoded(part)
633 elif subtype == 'message/rfc822':
634 # handle message/rfc822 specially - the name should be
635 # the subject of the actual e-mail embedded here
636 i = part.fp.tell()
637 mailmess = Message(part.fp)
638 name = mailmess.getheader('subject')
639 part.fp.seek(i)
640 attachments.append((name, 'message/rfc822', part.fp.read()))
641 else:
642 # try name on Content-Type
643 name = part.getparam('name')
644 # this is just an attachment
645 data = self.get_part_data_decoded(part)
646 attachments.append((name, part.gettype(), data))
647 if content is None:
648 raise MailUsageError, '''
649 Roundup requires the submission to be plain text. The message parser could
650 not find a text/plain part to use.
651 '''
653 elif content_type[:10] == 'multipart/':
654 # skip over the intro to the first boundary
655 message.getPart()
656 content = None
657 while 1:
658 # get the next part
659 part = message.getPart()
660 if part is None:
661 break
662 # parse it
663 if part.gettype() == 'text/plain' and not content:
664 content = self.get_part_data_decoded(part)
665 if content is None:
666 raise MailUsageError, '''
667 Roundup requires the submission to be plain text. The message parser could
668 not find a text/plain part to use.
669 '''
671 elif content_type != 'text/plain':
672 raise MailUsageError, '''
673 Roundup requires the submission to be plain text. The message parser could
674 not find a text/plain part to use.
675 '''
677 else:
678 content = self.get_part_data_decoded(message)
680 # figure how much we should muck around with the email body
681 keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT',
682 'no') == 'yes'
683 keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED',
684 'no') == 'yes'
686 # parse the body of the message, stripping out bits as appropriate
687 summary, content = parseContent(content, keep_citations,
688 keep_body)
690 #
691 # handle the attachments
692 #
693 files = []
694 for (name, mime_type, data) in attachments:
695 if not name:
696 name = "unnamed"
697 files.append(self.db.file.create(type=mime_type, name=name,
698 content=data))
700 #
701 # create the message if there's a message body (content)
702 #
703 if content:
704 message_id = self.db.msg.create(author=author,
705 recipients=recipients, date=date.Date('.'), summary=summary,
706 content=content, files=files, messageid=messageid,
707 inreplyto=inreplyto)
709 # attach the message to the node
710 if nodeid:
711 # add the message to the node's list
712 messages = cl.get(nodeid, 'messages')
713 messages.append(message_id)
714 props['messages'] = messages
715 else:
716 # pre-load the messages list
717 props['messages'] = [message_id]
719 # set the title to the subject
720 if properties.has_key('title') and not props.has_key('title'):
721 props['title'] = title
723 #
724 # perform the node change / create
725 #
726 try:
727 if nodeid:
728 cl.set(nodeid, **props)
729 else:
730 nodeid = cl.create(**props)
731 except (TypeError, IndexError, ValueError), message:
732 raise MailUsageError, '''
733 There was a problem with the message you sent:
734 %s
735 '''%message
737 # commit the changes to the DB
738 self.db.commit()
740 return nodeid
742 def extractUserFromList(userClass, users):
743 '''Given a list of users, try to extract the first non-anonymous user
744 and return that user, otherwise return None
745 '''
746 if len(users) > 1:
747 for user in users:
748 # make sure we don't match the anonymous or admin user
749 if userClass.get(user, 'username') in ('admin', 'anonymous'):
750 continue
751 # first valid match will do
752 return user
753 # well, I guess we have no choice
754 return user[0]
755 elif users:
756 return users[0]
757 return None
759 def uidFromAddress(db, address, create=1):
760 ''' address is from the rfc822 module, and therefore is (name, addr)
762 user is created if they don't exist in the db already
763 '''
764 (realname, address) = address
766 # try a straight match of the address
767 user = extractUserFromList(db.user, db.user.stringFind(address=address))
768 if user is not None: return user
770 # try the user alternate addresses if possible
771 props = db.user.getprops()
772 if props.has_key('alternate_addresses'):
773 users = db.user.filter(None, {'alternate_addresses': address},
774 [], [])
775 user = extractUserFromList(db.user, users)
776 if user is not None: return user
778 # try to match the username to the address (for local
779 # submissions where the address is empty)
780 user = extractUserFromList(db.user, db.user.stringFind(username=address))
782 # couldn't match address or username, so create a new user
783 if create:
784 return db.user.create(username=address, address=address,
785 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES)
786 else:
787 return 0
789 def parseContent(content, keep_citations, keep_body,
790 blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
791 eol=re.compile(r'[\r\n]+'),
792 signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
793 original_message=re.compile(r'^[>|\s]*-----Original Message-----$')):
794 ''' The message body is divided into sections by blank lines.
795 Sections where the second and all subsequent lines begin with a ">" or "|"
796 character are considered "quoting sections". The first line of the first
797 non-quoting section becomes the summary of the message.
798 '''
799 # strip off leading carriage-returns / newlines
800 i = 0
801 for i in range(len(content)):
802 if content[i] not in '\r\n':
803 break
804 if i > 0:
805 sections = blank_line.split(content[i:])
806 else:
807 sections = blank_line.split(content)
809 # extract out the summary from the message
810 summary = ''
811 l = []
812 for section in sections:
813 #section = section.strip()
814 if not section:
815 continue
816 lines = eol.split(section)
817 if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
818 lines[1] and lines[1][0] in '>|'):
819 # see if there's a response somewhere inside this section (ie.
820 # no blank line between quoted message and response)
821 for line in lines[1:]:
822 if line[0] not in '>|':
823 break
824 else:
825 # we keep quoted bits if specified in the config
826 if keep_citations:
827 l.append(section)
828 continue
829 # keep this section - it has reponse stuff in it
830 if not summary:
831 # and while we're at it, use the first non-quoted bit as
832 # our summary
833 summary = line
834 lines = lines[lines.index(line):]
835 section = '\n'.join(lines)
837 if not summary:
838 # if we don't have our summary yet use the first line of this
839 # section
840 summary = lines[0]
841 elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
842 # lose any signature
843 break
844 elif original_message.match(lines[0]):
845 # ditch the stupid Outlook quoting of the entire original message
846 break
848 # and add the section to the output
849 l.append(section)
850 # we only set content for those who want to delete cruft from the
851 # message body, otherwise the body is left untouched.
852 if not keep_body:
853 content = '\n\n'.join(l)
854 return summary, content
856 # vim: set filetype=python ts=4 sw=4 et si