Code

8c181aa27c73f4ba798551afb84a22fd269f22f2
[roundup.git] / roundup / mailgw.py
1 '''
2 An e-mail gateway for Roundup.
4 Incoming messages are examined for multiple parts:
5  . In a multipart/mixed message or part, each subpart is extracted and
6    examined. The text/plain subparts are assembled to form the textual
7    body of the message, to be stored in the file associated with a "msg"
8    class node. Any parts of other types are each stored in separate files
9    and given "file" class nodes that are linked to the "msg" node. 
10  . In a multipart/alternative message or part, we look for a text/plain
11    subpart and ignore the other parts.
13 Summary
14 -------
15 The "summary" property on message nodes is taken from the first non-quoting
16 section in the message body. The message body is divided into sections by
17 blank lines. Sections where the second and all subsequent lines begin with
18 a ">" or "|" character are considered "quoting sections". The first line of
19 the first non-quoting section becomes the summary of the message. 
21 Addresses
22 ---------
23 All of the addresses in the To: and Cc: headers of the incoming message are
24 looked up among the user nodes, and the corresponding users are placed in
25 the "recipients" property on the new "msg" node. The address in the From:
26 header similarly determines the "author" property of the new "msg"
27 node. The default handling for addresses that don't have corresponding
28 users is to create new users with no passwords and a username equal to the
29 address. (The web interface does not permit logins for users with no
30 passwords.) If we prefer to reject mail from outside sources, we can simply
31 register an auditor on the "user" class that prevents the creation of user
32 nodes with no passwords. 
34 Actions
35 -------
36 The subject line of the incoming message is examined to determine whether
37 the message is an attempt to create a new item or to discuss an existing
38 item. A designator enclosed in square brackets is sought as the first thing
39 on the subject line (after skipping any "Fwd:" or "Re:" prefixes). 
41 If an item designator (class name and id number) is found there, the newly
42 created "msg" node is added to the "messages" property for that item, and
43 any new "file" nodes are added to the "files" property for the item. 
45 If just an item class name is found there, we attempt to create a new item
46 of that class with its "messages" property initialized to contain the new
47 "msg" node and its "files" property initialized to contain any new "file"
48 nodes. 
50 Triggers
51 --------
52 Both cases may trigger detectors (in the first case we are calling the
53 set() method to add the message to the item's spool; in the second case we
54 are calling the create() method to create a new node). If an auditor raises
55 an exception, the original message is bounced back to the sender with the
56 explanatory message given in the exception. 
58 $Id: mailgw.py,v 1.7 2001-08-03 07:18:22 richard Exp $
59 '''
62 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
63 import traceback
64 import date
66 class Message(mimetools.Message):
67     ''' subclass mimetools.Message so we can retrieve the parts of the
68         message...
69     '''
70     def getPart(self):
71         ''' Get a single part of a multipart message and return it as a new
72             Message instance.
73         '''
74         boundary = self.getparam('boundary')
75         mid, end = '--'+boundary, '--'+boundary+'--'
76         s = cStringIO.StringIO()
77         while 1:
78             line = self.fp.readline()
79             if not line:
80                 break
81             if line.strip() in (mid, end):
82                 break
83             s.write(line)
84         if not s.getvalue().strip():
85             return None
86         s.seek(0)
87         return Message(s)
89 subject_re = re.compile(r'(\[?(fwd|re):\s*)*'
90     r'(\[(?P<classname>[^\d]+)(?P<nodeid>\d+)?\])'
91     r'(?P<title>[^\[]+)(\[(?P<args>.+?)\])?', re.I)
93 class MailGW:
94     def __init__(self, db):
95         self.db = db
97     def main(self, fp):
98         ''' fp - the file from which to read the Message.
100         Read a message from fp and then call handle_message() with the
101         result. This method's job is to make that call and handle any
102         errors in a sane manner. It should be replaced if you wish to
103         handle errors in a different manner.
104         '''
105         # ok, figure the subject, author, recipients and content-type
106         message = Message(fp)
107         try:
108             self.handle_message(message)
109         except:
110             # bounce the message back to the sender with the error message
111             sendto = [message.getaddrlist('from')[0][1]]
112             m = ['Subject: failed issue tracker submission']
113             m.append('')
114             # TODO as attachments?
115             m.append('----  traceback of failure  ----')
116             s = cStringIO.StringIO()
117             import traceback
118             traceback.print_exc(None, s)
119             m.append(s.getvalue())
120             m.append('---- failed message follows ----')
121             try:
122                 fp.seek(0)
123             except:
124                 pass
125             m.append(fp.read())
126             try:
127                 smtp = smtplib.SMTP(self.MAILHOST)
128                 smtp.sendmail(self.ADMIN_EMAIL, sendto, '\n'.join(m))
129             except socket.error, value:
130                 return "Couldn't send confirmation email: mailhost %s"%value
131             except smtplib.SMTPException, value:
132                 return "Couldn't send confirmation email: %s"%value
134     def handle_message(self, message):
135         ''' message - a Message instance
137         Parse the message as per the module docstring.
138         '''
139         # handle the subject line
140         m = subject_re.match(message.getheader('subject'))
141         if not m:
142             raise ValueError, 'No [designator] found in subject "%s"'
143         classname = m.group('classname')
144         nodeid = m.group('nodeid')
145         title = m.group('title').strip()
146         subject_args = m.group('args')
147         cl = self.db.getclass(classname)
148         properties = cl.getprops()
149         props = {}
150         args = m.group('args')
151         if args:
152             for prop in string.split(m.group('args'), ';'):
153                 try:
154                     key, value = prop.split('=')
155                 except ValueError, message:
156                     raise ValueError, 'Args list not of form [arg=value,value,...;arg=value,value,value..]  (specific exception message was "%s")'%message
157                 type =  properties[key]
158                 if type.isStringType:
159                     props[key] = value 
160                 elif type.isDateType:
161                     props[key] = date.Date(value)
162                 elif type.isIntervalType:
163                     props[key] = date.Interval(value)
164                 elif type.isLinkType:
165                     props[key] = value
166                 elif type.isMultilinkType:
167                     props[key] = value.split(',')
169         # handle the users
170         author = self.db.uidFromAddress(message.getaddrlist('from')[0])
171         recipients = []
172         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
173             if recipient[1].strip().lower() == self.ISSUE_TRACKER_EMAIL:
174                 continue
175             recipients.append(self.db.uidFromAddress(recipient))
177         # now handle the body - find the message
178         content_type =  message.gettype()
179         attachments = []
180         if content_type == 'multipart/mixed':
181             # skip over the intro to the first boundary
182             part = message.getPart()
183             content = None
184             while 1:
185                 # get the next part
186                 part = message.getPart()
187                 if part is None:
188                     break
189                 # parse it
190                 subtype = part.gettype()
191                 if subtype == 'text/plain' and not content:
192                     # add all text/plain parts to the message content
193                     if content is None:
194                         content = part.fp.read()
195                     else:
196                         content = content + part.fp.read()
198                 elif subtype == 'message/rfc822':
199                     # handle message/rfc822 specially - the name should be
200                     # the subject of the actual e-mail embedded here
201                     i = part.fp.tell()
202                     mailmess = Message(part.fp)
203                     name = mailmess.getheader('subject')
204                     part.fp.seek(i)
205                     attachments.append((name, 'message/rfc822', part.fp.read()))
207                 else:
208                     # try name on Content-Type
209                     name = part.getparam('name')
210                     # this is just an attachment
211                     data = part.fp.read()
212                     encoding = part.getencoding()
213                     if encoding == 'base64':
214                         data = binascii.a2b_base64(data)
215                     elif encoding == 'quoted-printable':
216                         data = quopri.decode(data)
217                     elif encoding == 'uuencoded':
218                         data = binascii.a2b_uu(data)
219                     attachments.append((name, part.gettype(), data))
221             if content is None:
222                 raise ValueError, 'No text/plain part found'
224         elif content_type[:10] == 'multipart/':
225             # skip over the intro to the first boundary
226             message.getPart()
227             content = None
228             while 1:
229                 # get the next part
230                 part = message.getPart()
231                 if part is None:
232                     break
233                 # parse it
234                 if part.gettype() == 'text/plain' and not content:
235                     # this one's our content
236                     content = part.fp.read()
237             if content is None:
238                 raise ValueError, 'No text/plain part found'
240         elif content_type != 'text/plain':
241             raise ValueError, 'No text/plain part found'
243         else:
244             content = message.fp.read()
246         summary, content = parseContent(content)
248         # handle the files
249         files = []
250         for (name, type, data) in attachments:
251             files.append(self.db.file.create(type=type, name=name,
252                 content=data))
254         # now handle the db stuff
255         if nodeid:
256             # If an item designator (class name and id number) is found there,
257             # the newly created "msg" node is added to the "messages" property
258             # for that item, and any new "file" nodes are added to the "files" 
259             # property for the item. 
260             message_id = self.db.msg.create(author=author,
261                 recipients=recipients, date=date.Date('.'), summary=summary,
262                 content=content, files=files)
263             messages = cl.get(nodeid, 'messages')
264             messages.append(message_id)
265             props['messages'] = messages
266             cl.set(nodeid, **props)
267         else:
268             # If just an item class name is found there, we attempt to create a
269             # new item of that class with its "messages" property initialized to
270             # contain the new "msg" node and its "files" property initialized to
271             # contain any new "file" nodes. 
272             message_id = self.db.msg.create(author=author,
273                 recipients=recipients, date=date.Date('.'), summary=summary,
274                 content=content, files=files)
275             # fill out the properties with defaults where required
276             if properties.has_key('assignedto') and \
277                     not props.has_key('assignedto'):
278                 props['assignedto'] = '1'             # "admin"
279             if properties.has_key('status') and not props.has_key('status'):
280                 props['status'] = '1'                 # "unread"
281             if properties.has_key('title') and not props.has_key('title'):
282                 props['title'] = title
283             props['messages'] = [message_id]
284             props['nosy'] = recipients[:]
285             props['nosy'].append(author)
286             props['nosy'].sort()
287             nodeid = cl.create(**props)
289 def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
290         eol=re.compile(r'[\r\n]+'), signature=re.compile(r'^[>|\s]*[-_]+\s*$')):
291     ''' The message body is divided into sections by blank lines.
292     Sections where the second and all subsequent lines begin with a ">" or "|"
293     character are considered "quoting sections". The first line of the first
294     non-quoting section becomes the summary of the message. 
295     '''
296     sections = blank_line.split(content)
297     # extract out the summary from the message
298     summary = ''
299     l = []
300     print sections
301     for section in sections:
302         section = section.strip()
303         if not section:
304             continue
305         lines = eol.split(section)
306         if lines[0] and lines[0][0] in '>|':
307             continue
308         if len(lines) > 1 and lines[1] and lines[1][0] in '>|':
309             continue
310         if not summary:
311             summary = lines[0]
312             l.append(section)
313             continue
314         if signature.match(lines[0]):
315             break
316         l.append(section)
317     return summary, '\n'.join(l)
320 # $Log: not supported by cvs2svn $
321 # Revision 1.6  2001/08/01 04:24:21  richard
322 # mailgw was assuming certain properties existed on the issues being created.
324 # Revision 1.5  2001/07/29 07:01:39  richard
325 # Added vim command to all source so that we don't get no steenkin' tabs :)
327 # Revision 1.4  2001/07/28 06:43:02  richard
328 # Multipart message class has the getPart method now. Added some tests for it.
330 # Revision 1.3  2001/07/28 00:34:34  richard
331 # Fixed some non-string node ids.
333 # Revision 1.2  2001/07/22 12:09:32  richard
334 # Final commit of Grande Splite
337 # vim: set filetype=python ts=4 sw=4 et si