Code

c250c623666ff29c8325317bf4d6bf9712ba7c4f
[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.46 2002-01-02 02:31:38 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 UnAuthorized(Exception):
94     """ Access denied """
96 class Message(mimetools.Message):
97     ''' subclass mimetools.Message so we can retrieve the parts of the
98         message...
99     '''
100     def getPart(self):
101         ''' Get a single part of a multipart message and return it as a new
102             Message instance.
103         '''
104         boundary = self.getparam('boundary')
105         mid, end = '--'+boundary, '--'+boundary+'--'
106         s = cStringIO.StringIO()
107         while 1:
108             line = self.fp.readline()
109             if not line:
110                 break
111             if line.strip() in (mid, end):
112                 break
113             s.write(line)
114         if not s.getvalue().strip():
115             return None
116         s.seek(0)
117         return Message(s)
119 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re)\s*\W?\s*)*'
120     r'\s*(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])'
121     r'\s*(?P<title>[^[]+)?(\[(?P<args>.+?)\])?', re.I)
123 class MailGW:
124     def __init__(self, instance, db):
125         self.instance = instance
126         self.db = db
128     def main(self, fp):
129         ''' fp - the file from which to read the Message.
130         '''
131         self.handle_Message(Message(fp))
133     def handle_Message(self, message):
134         '''Handle an RFC822 Message
136         Handle the Message object by calling handle_message() and then cope
137         with any errors raised by handle_message.
138         This method's job is to make that call and handle any
139         errors in a sane manner. It should be replaced if you wish to
140         handle errors in a different manner.
141         '''
142         # in some rare cases, a particularly stuffed-up e-mail will make
143         # its way into here... try to handle it gracefully
144         sendto = message.getaddrlist('from')
145         if sendto:
146             try:
147                 return self.handle_message(message)
148             except MailUsageError, value:
149                 # bounce the message back to the sender with the usage message
150                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
151                 sendto = [sendto[0][1]]
152                 m = ['']
153                 m.append(str(value))
154                 m.append('\n\nMail Gateway Help\n=================')
155                 m.append(fulldoc)
156                 m = self.bounce_message(message, sendto, m)
157             except UnAuthorized, value:
158                 # just inform the user that he is not authorized
159                 sendto = [sendto[0][1]]
160                 m = ['']
161                 m.append(str(value))
162                 m = self.bounce_message(message, sendto, m)
163             except:
164                 # bounce the message back to the sender with the error message
165                 sendto = [sendto[0][1]]
166                 m = ['']
167                 m.append('----  traceback of failure  ----')
168                 s = cStringIO.StringIO()
169                 import traceback
170                 traceback.print_exc(None, s)
171                 m.append(s.getvalue())
172                 m = self.bounce_message(message, sendto, m)
173         else:
174             # very bad-looking message - we don't even know who sent it
175             sendto = [self.ADMIN_EMAIL]
176             m = ['Subject: badly formed message from mail gateway']
177             m.append('')
178             m.append('The mail gateway retrieved a message which has no From:')
179             m.append('line, indicating that it is corrupt. Please check your')
180             m.append('mail gateway source. Failed message is attached.')
181             m.append('')
182             m = self.bounce_message(message, sendto, m,
183                 subject='Badly formed message from mail gateway')
185         # now send the message
186         if SENDMAILDEBUG:
187             open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
188                 self.ADMIN_EMAIL, ', '.join(sendto), m.getvalue()))
189         else:
190             try:
191                 smtp = smtplib.SMTP(self.MAILHOST)
192                 smtp.sendmail(self.ADMIN_EMAIL, sendto, m.getvalue())
193             except socket.error, value:
194                 raise MailGWError, "Couldn't send error email: "\
195                     "mailhost %s"%value
196             except smtplib.SMTPException, value:
197                 raise MailGWError, "Couldn't send error email: %s"%value
199     def bounce_message(self, message, sendto, error,
200             subject='Failed issue tracker submission'):
201         ''' create a message that explains the reason for the failed
202             issue submission to the author and attach the original
203             message.
204         '''
205         msg = cStringIO.StringIO()
206         writer = MimeWriter.MimeWriter(msg)
207         writer.addheader('Subject', subject)
208         writer.addheader('From', '%s <%s>'% (self.instance.INSTANCE_NAME,
209                                             self.ISSUE_TRACKER_EMAIL))
210         writer.addheader('To', ','.join(sendto))
211         writer.addheader('MIME-Version', '1.0')
212         part = writer.startmultipartbody('mixed')
213         part = writer.nextpart()
214         body = part.startbody('text/plain')
215         body.write('\n'.join(error))
217         # reconstruct the original message
218         m = cStringIO.StringIO()
219         w = MimeWriter.MimeWriter(m)
220         # default the content_type, just in case...
221         content_type = 'text/plain'
222         # add the headers except the content-type
223         for header in message.headers:
224             header_name = header.split(':')[0]
225             if header_name.lower() == 'content-type':
226                 content_type = message.getheader(header_name)
227             elif message.getheader(header_name):
228                 w.addheader(header_name, message.getheader(header_name))
229         # now attach the message body
230         body = w.startbody(content_type)
231         message.rewindbody()
232         body.write(message.fp.read())
234         # attach the original message to the returned message
235         part = writer.nextpart()
236         part.addheader('Content-Disposition','attachment')
237         part.addheader('Content-Description','Message that caused the error')
238         part.addheader('Content-Transfer-Encoding', '7bit')
239         body = part.startbody('message/rfc822')
240         body.write(m.getvalue())
242         writer.lastpart()
243         return msg
245     def handle_message(self, message):
246         ''' message - a Message instance
248         Parse the message as per the module docstring.
249         '''
250         # handle the subject line
251         subject = message.getheader('subject', '')
252         m = subject_re.match(subject)
253         if not m:
254             raise MailUsageError, '''
255 The message you sent to roundup did not contain a properly formed subject
256 line. The subject must contain a class name or designator to indicate the
257 "topic" of the message. For example:
258     Subject: [issue] This is a new issue
259       - this will create a new issue in the tracker with the title "This is
260         a new issue".
261     Subject: [issue1234] This is a followup to issue 1234
262       - this will append the message's contents to the existing issue 1234
263         in the tracker.
265 Subject was: "%s"
266 '''%subject
268         # get the classname
269         classname = m.group('classname')
270         try:
271             cl = self.db.getclass(classname)
272         except KeyError:
273             raise MailUsageError, '''
274 The class name you identified in the subject line ("%s") does not exist in the
275 database.
277 Valid class names are: %s
278 Subject was: "%s"
279 '''%(classname, ', '.join(self.db.getclasses()), subject)
281         # get the optional nodeid
282         nodeid = m.group('nodeid')
284         # title is optional too
285         title = m.group('title')
286         if title:
287             title = title.strip()
288         else:
289             title = ''
291         # but we do need either a title or a nodeid...
292         if not nodeid and not title:
293             raise MailUsageError, '''
294 I cannot match your message to a node in the database - you need to either
295 supply a full node identifier (with number, eg "[issue123]" or keep the
296 previous subject title intact so I can match that.
298 Subject was: "%s"
299 '''%subject
301         # extract the args
302         subject_args = m.group('args')
304         # If there's no nodeid, check to see if this is a followup and
305         # maybe someone's responded to the initial mail that created an
306         # entry. Try to find the matching nodes with the same title, and
307         # use the _last_ one matched (since that'll _usually_ be the most
308         # recent...)
309         if not nodeid and m.group('refwd'):
310             l = cl.stringFind(title=title)
311             if l:
312                 nodeid = l[-1]
314         # start of the props
315         properties = cl.getprops()
316         props = {}
318         # handle the args
319         args = m.group('args')
320         if args:
321             for prop in string.split(args, ';'):
322                 try:
323                     key, value = prop.split('=')
324                 except ValueError, message:
325                     raise MailUsageError, '''
326 Subject argument list not of form [arg=value,value,...;arg=value,value...]
327    (specific exception message was "%s")
329 Subject was: "%s"
330 '''%(message, subject)
331                 key = key.strip()
332                 try:
333                     proptype =  properties[key]
334                 except KeyError:
335                     raise MailUsageError, '''
336 Subject argument list refers to an invalid property: "%s"
338 Subject was: "%s"
339 '''%(key, subject)
340                 if isinstance(proptype, hyperdb.String):
341                     props[key] = value.strip()
342                 if isinstance(proptype, hyperdb.Password):
343                     props[key] = password.Password(value.strip())
344                 elif isinstance(proptype, hyperdb.Date):
345                     try:
346                         props[key] = date.Date(value.strip())
347                     except ValueError, message:
348                         raise UsageError, '''
349 Subject argument list contains an invalid date for %s.
351 Error was: %s
352 Subject was: "%s"
353 '''%(key, message, subject)
354                 elif isinstance(proptype, hyperdb.Interval):
355                     try:
356                         props[key] = date.Interval(value) # no strip needed
357                     except ValueError, message:
358                         raise UsageError, '''
359 Subject argument list contains an invalid date interval for %s.
361 Error was: %s
362 Subject was: "%s"
363 '''%(key, message, subject)
364                 elif isinstance(proptype, hyperdb.Link):
365                     link = self.db.classes[proptype.classname]
366                     propkey = link.labelprop(default_to_id=1)
367                     try:
368                         props[key] = link.get(value.strip(), propkey)
369                     except:
370                         props[key] = link.lookup(value.strip())
371                 elif isinstance(proptype, hyperdb.Multilink):
372                     link = self.db.classes[proptype.classname]
373                     propkey = link.labelprop(default_to_id=1)
374                     l = [x.strip() for x in value.split(',')]
375                     for item in l:
376                         try:
377                             v = link.get(item, propkey)
378                         except:
379                             v = link.lookup(item)
380                         if props.has_key(key):
381                             props[key].append(v)
382                         else:
383                             props[key] = [v]
385         #
386         # handle the users
387         #
389         # Don't create users if ANONYMOUS_REGISTER is denied
390         if self.ANONYMOUS_ACCESS == 'deny':
391             create = 0
392         else:
393             create = 1
394         author = self.db.uidFromAddress(message.getaddrlist('from')[0],
395             create=create)
396         if not author:
397             raise UnAuthorized, '''
398 You are not a registered user.
400 Unknown address: %s
401 '''%message.getaddrlist('from')[0][1]
403         # the author may have been created - make sure the change is
404         # committed before we reopen the database
405         self.db.commit()
406             
407         # reopen the database as the author
408         username = self.db.user.get(author, 'username')
409         self.db = self.instance.open(username)
411         # re-get the class with the new database connection
412         cl = self.db.getclass(classname)
414         # now update the recipients list
415         recipients = []
416         tracker_email = self.ISSUE_TRACKER_EMAIL.lower()
417         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
418             r = recipient[1].strip().lower()
419             if r == tracker_email or not r:
420                 continue
421             recipients.append(self.db.uidFromAddress(recipient))
423         #
424         # handle message-id and in-reply-to
425         #
426         messageid = message.getheader('message-id')
427         inreplyto = message.getheader('in-reply-to') or ''
428         # generate a messageid if there isn't one
429         if not messageid:
430             messageid = "%s.%s.%s%s-%s"%(time.time(), random.random(),
431                 classname, nodeid, self.MAIL_DOMAIN)
433         #
434         # now handle the body - find the message
435         #
436         content_type =  message.gettype()
437         attachments = []
438         if content_type == 'multipart/mixed':
439             # skip over the intro to the first boundary
440             part = message.getPart()
441             content = None
442             while 1:
443                 # get the next part
444                 part = message.getPart()
445                 if part is None:
446                     break
447                 # parse it
448                 subtype = part.gettype()
449                 if subtype == 'text/plain' and not content:
450                     # add all text/plain parts to the message content
451                     if content is None:
452                         content = part.fp.read()
453                     else:
454                         content = content + part.fp.read()
456                 elif subtype == 'message/rfc822':
457                     # handle message/rfc822 specially - the name should be
458                     # the subject of the actual e-mail embedded here
459                     i = part.fp.tell()
460                     mailmess = Message(part.fp)
461                     name = mailmess.getheader('subject')
462                     part.fp.seek(i)
463                     attachments.append((name, 'message/rfc822', part.fp.read()))
465                 else:
466                     # try name on Content-Type
467                     name = part.getparam('name')
468                     # this is just an attachment
469                     encoding = part.getencoding()
470                     if encoding == 'base64':
471                         data = binascii.a2b_base64(part.fp.read())
472                     elif encoding == 'quoted-printable':
473                         # the quopri module wants to work with files
474                         decoded = cStringIO.StringIO()
475                         quopri.decode(part.fp, decoded)
476                         data = decoded.getvalue()
477                     elif encoding == 'uuencoded':
478                         data = binascii.a2b_uu(part.fp.read())
479                     attachments.append((name, part.gettype(), data))
481             if content is None:
482                 raise MailUsageError, '''
483 Roundup requires the submission to be plain text. The message parser could
484 not find a text/plain part to use.
485 '''
487         elif content_type[:10] == 'multipart/':
488             # skip over the intro to the first boundary
489             message.getPart()
490             content = None
491             while 1:
492                 # get the next part
493                 part = message.getPart()
494                 if part is None:
495                     break
496                 # parse it
497                 if part.gettype() == 'text/plain' and not content:
498                     # this one's our content
499                     content = part.fp.read()
500             if content is None:
501                 raise MailUsageError, '''
502 Roundup requires the submission to be plain text. The message parser could
503 not find a text/plain part to use.
504 '''
506         elif content_type != 'text/plain':
507             raise MailUsageError, '''
508 Roundup requires the submission to be plain text. The message parser could
509 not find a text/plain part to use.
510 '''
512         else:
513             content = message.fp.read()
515         summary, content = parseContent(content)
517         # 
518         # handle the attachments
519         #
520         files = []
521         for (name, mime_type, data) in attachments:
522             files.append(self.db.file.create(type=mime_type, name=name,
523                 content=data))
525         #
526         # now handle the db stuff
527         #
528         if nodeid:
529             # If an item designator (class name and id number) is found there,
530             # the newly created "msg" node is added to the "messages" property
531             # for that item, and any new "file" nodes are added to the "files" 
532             # property for the item. 
534             # if the message is currently 'unread' or 'resolved', then set
535             # it to 'chatting'
536             if properties.has_key('status'):
537                 try:
538                     # determine the id of 'unread', 'resolved' and 'chatting'
539                     unread_id = self.db.status.lookup('unread')
540                     resolved_id = self.db.status.lookup('resolved')
541                     chatting_id = self.db.status.lookup('chatting')
542                 except KeyError:
543                     pass
544                 else:
545                     if (not props.has_key('status') and
546                             properties['status'] == unread_id or
547                             properties['status'] == resolved_id):
548                         props['status'] = chatting_id
550             # add nosy in arguments to issue's nosy list
551             if not props.has_key('nosy'): props['nosy'] = []
552             n = {}
553             for nid in cl.get(nodeid, 'nosy'):
554                 n[nid] = 1
555             for value in props['nosy']:
556                 if self.db.hasnode('user', value):
557                     nid = value
558                 else: 
559                     continue
560                 if n.has_key(nid): continue
561                 n[nid] = 1
562             props['nosy'] = n.keys()
563             # add assignedto to the nosy list
564             try:
565                 assignedto = self.db.user.lookup(props['assignedto'])
566                 if assignedto not in props['nosy']:
567                     props['nosy'].append(assignedto)
568             except:
569                 pass
571             message_id = self.db.msg.create(author=author,
572                 recipients=recipients, date=date.Date('.'), summary=summary,
573                 content=content, files=files, messageid=messageid,
574                 inreplyto=inreplyto)
575             try:
576                 messages = cl.get(nodeid, 'messages')
577             except IndexError:
578                 raise MailUsageError, '''
579 The node specified by the designator in the subject of your message ("%s")
580 does not exist.
582 Subject was: "%s"
583 '''%(nodeid, subject)
584             messages.append(message_id)
585             props['messages'] = messages
587             # now apply the changes
588             try:
589                 cl.set(nodeid, **props)
590             except (TypeError, IndexError, ValueError), message:
591                 raise MailUsageError, '''
592 There was a problem with the message you sent:
593    %s
594 '''%message
595             # commit the changes to the DB
596             self.db.commit()
597         else:
598             # If just an item class name is found there, we attempt to create a
599             # new item of that class with its "messages" property initialized to
600             # contain the new "msg" node and its "files" property initialized to
601             # contain any new "file" nodes. 
602             message_id = self.db.msg.create(author=author,
603                 recipients=recipients, date=date.Date('.'), summary=summary,
604                 content=content, files=files, messageid=messageid,
605                 inreplyto=inreplyto)
607             # pre-set the issue to unread
608             if properties.has_key('status') and not props.has_key('status'):
609                 try:
610                     # determine the id of 'unread'
611                     unread_id = self.db.status.lookup('unread')
612                 except KeyError:
613                     pass
614                 else:
615                     props['status'] = '1'
617             # set the title to the subject
618             if properties.has_key('title') and not props.has_key('title'):
619                 props['title'] = title
621             # pre-load the messages list
622             props['messages'] = [message_id]
624             # set up (clean) the nosy list
625             nosy = props.get('nosy', [])
626             n = {}
627             for value in nosy:
628                 if self.db.hasnode('user', value):
629                     nid = value
630                 else:
631                     continue
632                 if n.has_key(nid): continue
633                 n[nid] = 1
634             props['nosy'] = n.keys()
635             # add on the recipients of the message
636             for recipient in recipients:
637                 if not n.has_key(recipient):
638                     props['nosy'].append(recipient)
639                     n[recipient] = 1
641             # add the author to the nosy list
642             if not n.has_key(author):
643                 props['nosy'].append(author)
644                 n[author] = 1
646             # add assignedto to the nosy list
647             if properties.has_key('assignedto') and props.has_key('assignedto'):
648                 try:
649                     assignedto = self.db.user.lookup(props['assignedto'])
650                 except KeyError:
651                     raise MailUsageError, '''
652 There was a problem with the message you sent:
653    Assignedto user '%s' doesn't exist
654 '''%props['assignedto']
655                 if not n.has_key(assignedto):
656                     props['nosy'].append(assignedto)
657                     n[assignedto] = 1
659             # and attempt to create the new node
660             try:
661                 nodeid = cl.create(**props)
662             except (TypeError, IndexError, ValueError), message:
663                 raise MailUsageError, '''
664 There was a problem with the message you sent:
665    %s
666 '''%message
668             # commit the new node(s) to the DB
669             self.db.commit()
671 def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
672         eol=re.compile(r'[\r\n]+'), signature=re.compile(r'^[>|\s]*[-_]+\s*$')):
673     ''' The message body is divided into sections by blank lines.
674     Sections where the second and all subsequent lines begin with a ">" or "|"
675     character are considered "quoting sections". The first line of the first
676     non-quoting section becomes the summary of the message. 
677     '''
678     # strip off leading carriage-returns / newlines
679     i = 0
680     for i in range(len(content)):
681         if content[i] not in '\r\n':
682             break
683     if i > 0:
684         sections = blank_line.split(content[i:])
685     else:
686         sections = blank_line.split(content)
688     # extract out the summary from the message
689     summary = ''
690     l = []
691     for section in sections:
692         #section = section.strip()
693         if not section:
694             continue
695         lines = eol.split(section)
696         if lines[0] and lines[0][0] in '>|':
697             continue
698         if len(lines) > 1 and lines[1] and lines[1][0] in '>|':
699             continue
700         if not summary:
701             summary = lines[0]
702             l.append(section)
703             continue
704         if signature.match(lines[0]):
705             break
706         l.append(section)
707     return summary, '\n\n'.join(l)
710 # $Log: not supported by cvs2svn $
711 # Revision 1.45  2001/12/20 15:43:01  rochecompaan
712 # Features added:
713 #  .  Multilink properties are now displayed as comma separated values in
714 #     a textbox
715 #  .  The add user link is now only visible to the admin user
716 #  .  Modified the mail gateway to reject submissions from unknown
717 #     addresses if ANONYMOUS_ACCESS is denied
719 # Revision 1.44  2001/12/18 15:30:34  rochecompaan
720 # Fixed bugs:
721 #  .  Fixed file creation and retrieval in same transaction in anydbm
722 #     backend
723 #  .  Cgi interface now renders new issue after issue creation
724 #  .  Could not set issue status to resolved through cgi interface
725 #  .  Mail gateway was changing status back to 'chatting' if status was
726 #     omitted as an argument
728 # Revision 1.43  2001/12/15 19:39:01  rochecompaan
729 # Oops.
731 # Revision 1.42  2001/12/15 19:24:39  rochecompaan
732 #  . Modified cgi interface to change properties only once all changes are
733 #    collected, files created and messages generated.
734 #  . Moved generation of change note to nosyreactors.
735 #  . We now check for changes to "assignedto" to ensure it's added to the
736 #    nosy list.
738 # Revision 1.41  2001/12/10 00:57:38  richard
739 # From CHANGES:
740 #  . Added the "display" command to the admin tool - displays a node's values
741 #  . #489760 ] [issue] only subject
742 #  . fixed the doc/index.html to include the quoting in the mail alias.
744 # Also:
745 #  . fixed roundup-admin so it works with transactions
746 #  . disabled the back_anydbm module if anydbm tries to use dumbdbm
748 # Revision 1.40  2001/12/05 14:26:44  rochecompaan
749 # Removed generation of change note from "sendmessage" in roundupdb.py.
750 # The change note is now generated when the message is created.
752 # Revision 1.39  2001/12/02 05:06:16  richard
753 # . We now use weakrefs in the Classes to keep the database reference, so
754 #   the close() method on the database is no longer needed.
755 #   I bumped the minimum python requirement up to 2.1 accordingly.
756 # . #487480 ] roundup-server
757 # . #487476 ] INSTALL.txt
759 # I also cleaned up the change message / post-edit stuff in the cgi client.
760 # There's now a clearly marked "TODO: append the change note" where I believe
761 # the change note should be added there. The "changes" list will obviously
762 # have to be modified to be a dict of the changes, or somesuch.
764 # More testing needed.
766 # Revision 1.38  2001/12/01 07:17:50  richard
767 # . We now have basic transaction support! Information is only written to
768 #   the database when the commit() method is called. Only the anydbm
769 #   backend is modified in this way - neither of the bsddb backends have been.
770 #   The mail, admin and cgi interfaces all use commit (except the admin tool
771 #   doesn't have a commit command, so interactive users can't commit...)
772 # . Fixed login/registration forwarding the user to the right page (or not,
773 #   on a failure)
775 # Revision 1.37  2001/11/28 21:55:35  richard
776 #  . login_action and newuser_action return values were being ignored
777 #  . Woohoo! Found that bloody re-login bug that was killing the mail
778 #    gateway.
779 #  (also a minor cleanup in hyperdb)
781 # Revision 1.36  2001/11/26 22:55:56  richard
782 # Feature:
783 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
784 #    the instance.
785 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
786 #    signature info in e-mails.
787 #  . Some more flexibility in the mail gateway and more error handling.
788 #  . Login now takes you to the page you back to the were denied access to.
790 # Fixed:
791 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
793 # Revision 1.35  2001/11/22 15:46:42  jhermann
794 # Added module docstrings to all modules.
796 # Revision 1.34  2001/11/15 10:24:27  richard
797 # handle the case where there is no file attached
799 # Revision 1.33  2001/11/13 21:44:44  richard
800 #  . re-open the database as the author in mail handling
802 # Revision 1.32  2001/11/12 22:04:29  richard
803 # oops, left debug in there
805 # Revision 1.31  2001/11/12 22:01:06  richard
806 # Fixed issues with nosy reaction and author copies.
808 # Revision 1.30  2001/11/09 22:33:28  richard
809 # More error handling fixes.
811 # Revision 1.29  2001/11/07 05:29:26  richard
812 # Modified roundup-mailgw so it can read e-mails from a local mail spool
813 # file. Truncates the spool file after parsing.
814 # Fixed a couple of small bugs introduced in roundup.mailgw when I started
815 # the popgw.
817 # Revision 1.28  2001/11/01 22:04:37  richard
818 # Started work on supporting a pop3-fetching server
819 # Fixed bugs:
820 #  . bug #477104 ] HTML tag error in roundup-server
821 #  . bug #477107 ] HTTP header problem
823 # Revision 1.27  2001/10/30 11:26:10  richard
824 # Case-insensitive match for ISSUE_TRACKER_EMAIL in address in e-mail.
826 # Revision 1.26  2001/10/30 00:54:45  richard
827 # Features:
828 #  . #467129 ] Lossage when username=e-mail-address
829 #  . #473123 ] Change message generation for author
830 #  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
832 # Revision 1.25  2001/10/28 23:22:28  richard
833 # fixed bug #474749 ] Indentations lost
835 # Revision 1.24  2001/10/23 22:57:52  richard
836 # Fix unread->chatting auto transition, thanks Roch'e
838 # Revision 1.23  2001/10/21 04:00:20  richard
839 # MailGW now moves 'unread' to 'chatting' on receiving e-mail for an issue.
841 # Revision 1.22  2001/10/21 03:35:13  richard
842 # bug #473125: Paragraph in e-mails
844 # Revision 1.21  2001/10/21 00:53:42  richard
845 # bug #473130: Nosy list not set correctly
847 # Revision 1.20  2001/10/17 23:13:19  richard
848 # Did a fair bit of work on the admin tool. Now has an extra command "table"
849 # which displays node information in a tabular format. Also fixed import and
850 # export so they work. Removed freshen.
851 # Fixed quopri usage in mailgw from bug reports.
853 # Revision 1.19  2001/10/11 23:43:04  richard
854 # Implemented the comma-separated printing option in the admin tool.
855 # Fixed a typo (more of a vim-o actually :) in mailgw.
857 # Revision 1.18  2001/10/11 06:38:57  richard
858 # Initial cut at trying to handle people responding to CC'ed messages that
859 # create an issue.
861 # Revision 1.17  2001/10/09 07:25:59  richard
862 # Added the Password property type. See "pydoc roundup.password" for
863 # implementation details. Have updated some of the documentation too.
865 # Revision 1.16  2001/10/05 02:23:24  richard
866 #  . roundup-admin create now prompts for property info if none is supplied
867 #    on the command-line.
868 #  . hyperdb Class getprops() method may now return only the mutable
869 #    properties.
870 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
871 #    now support anonymous user access (read-only, unless there's an
872 #    "anonymous" user, in which case write access is permitted). Login
873 #    handling has been moved into cgi_client.Client.main()
874 #  . The "extended" schema is now the default in roundup init.
875 #  . The schemas have had their page headings modified to cope with the new
876 #    login handling. Existing installations should copy the interfaces.py
877 #    file from the roundup lib directory to their instance home.
878 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
879 #    Ping - has been removed.
880 #  . Fixed a whole bunch of places in the CGI interface where we should have
881 #    been returning Not Found instead of throwing an exception.
882 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
883 #    an item now throws an exception.
885 # Revision 1.15  2001/08/30 06:01:17  richard
886 # Fixed missing import in mailgw :(
888 # Revision 1.14  2001/08/13 23:02:54  richard
889 # Make the mail parser a little more robust.
891 # Revision 1.13  2001/08/12 06:32:36  richard
892 # using isinstance(blah, Foo) now instead of isFooType
894 # Revision 1.12  2001/08/08 01:27:00  richard
895 # Added better error handling to mailgw.
897 # Revision 1.11  2001/08/08 00:08:03  richard
898 # oops ;)
900 # Revision 1.10  2001/08/07 00:24:42  richard
901 # stupid typo
903 # Revision 1.9  2001/08/07 00:15:51  richard
904 # Added the copyright/license notice to (nearly) all files at request of
905 # Bizar Software.
907 # Revision 1.8  2001/08/05 07:06:07  richard
908 # removed some print statements
910 # Revision 1.7  2001/08/03 07:18:22  richard
911 # Implemented correct mail splitting (was taking a shortcut). Added unit
912 # tests. Also snips signatures now too.
914 # Revision 1.6  2001/08/01 04:24:21  richard
915 # mailgw was assuming certain properties existed on the issues being created.
917 # Revision 1.5  2001/07/29 07:01:39  richard
918 # Added vim command to all source so that we don't get no steenkin' tabs :)
920 # Revision 1.4  2001/07/28 06:43:02  richard
921 # Multipart message class has the getPart method now. Added some tests for it.
923 # Revision 1.3  2001/07/28 00:34:34  richard
924 # Fixed some non-string node ids.
926 # Revision 1.2  2001/07/22 12:09:32  richard
927 # Final commit of Grande Splite
930 # vim: set filetype=python ts=4 sw=4 et si