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()
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)
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