Code

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