Code

oops ;)
[roundup.git] / roundup-mailgw.py
1 #! /usr/bin/python
2 '''
3 Incoming messages are examined for multiple parts. In a multipart/mixed
4 message or part, each subpart is extracted and examined. In a
5 multipart/alternative message or part, we look for a text/plain subpart and
6 ignore the other parts. The text/plain subparts are assembled to form the
7 textual body of the message, to be stored in the file associated with a
8 "msg" class node. Any parts of other types are each stored in separate
9 files and given "file" class nodes that are linked to the "msg" node. 
11 The "summary" property on message nodes is taken from the first non-quoting
12 section in the message body. The message body is divided into sections by
13 blank lines. Sections where the second and all subsequent lines begin with
14 a ">" or "|" character are considered "quoting sections". The first line of
15 the first non-quoting section becomes the summary of the message. 
17 All of the addresses in the To: and Cc: headers of the incoming message are
18 looked up among the user nodes, and the corresponding users are placed in
19 the "recipients" property on the new "msg" node. The address in the From:
20 header similarly determines the "author" property of the new "msg"
21 node. The default handling for addresses that don't have corresponding
22 users is to create new users with no passwords and a username equal to the
23 address. (The web interface does not permit logins for users with no
24 passwords.) If we prefer to reject mail from outside sources, we can simply
25 register an auditor on the "user" class that prevents the creation of user
26 nodes with no passwords. 
28 The subject line of the incoming message is examined to determine whether
29 the message is an attempt to create a new item or to discuss an existing
30 item. A designator enclosed in square brackets is sought as the first thing
31 on the subject line (after skipping any "Fwd:" or "Re:" prefixes). 
33 If an item designator (class name and id number) is found there, the newly
34 created "msg" node is added to the "messages" property for that item, and
35 any new "file" nodes are added to the "files" property for the item. 
37 If just an item class name is found there, we attempt to create a new item
38 of that class with its "messages" property initialized to contain the new
39 "msg" node and its "files" property initialized to contain any new "file"
40 nodes. 
42 Both cases may trigger detectors (in the first case we are calling the
43 set() method to add the message to the item's spool; in the second case we
44 are calling the create() method to create a new node). If an auditor raises
45 an exception, the original message is bounced back to the sender with the
46 explanatory message given in the exception. 
48 $Id: roundup-mailgw.py,v 1.3 2001-07-19 06:27:07 anthonybaxter Exp $
49 '''
51 import sys
52 if int(sys.version[0]) < 2:
53     print "Roundup requires Python 2.0 or newer."
54     sys.exit(0)
56 import string, re, os, mimetools, StringIO, smtplib, socket, binascii, quopri
57 import config, date, roundupdb
59 def getPart(fp, boundary):
60     line = ''
61     s = StringIO.StringIO()
62     while 1:
63         line_n = fp.readline()
64         if not line_n:
65             break
66         line = line_n.strip()
67         if line == '--'+boundary+'--':
68             break
69         if line == '--'+boundary:
70             break
71         s.write(line_n)
72     if not s.getvalue().strip():
73         return None
74     return s
76 subject_re = re.compile(r'(\[?(fwd|re):\s*)*'
77     r'(\[(?P<classname>[^\d]+)(?P<nodeid>\d+)?\])'
78     r'(?P<title>[^\[]+)(\[(?P<args>.+?)\])?', re.I)
80 def roundup_mail(db, fp):
81     # ok, figure the subject, author, recipients and content-type
82     message = mimetools.Message(fp)
83     try:
84         handle_message(db, message)
85     except:
86         # send an email to the people who missed out
87         sendto = [message.getaddrlist('from')[0][1]]
88         m = ['Subject: failed issue tracker submission']
89         m.append('')
90         # TODO as attachments?
91         m.append('----  traceback of failure  ----')
92         return
93         s = StringIO.StringIO()
94         import traceback
95         traceback.print_exc(None, s)
96         m.append(s.getvalue())
97         m.append('---- failed message follows ----')
98         try:
99             fp.seek(0)
100         except:
101             pass
102         m.append(fp.read())
103         try:
104             smtp = smtplib.SMTP(config.MAILHOST)
105             smtp.sendmail(config.ADMIN_EMAIL, sendto, '\n'.join(m))
106         except socket.error, value:
107             return "Couldn't send confirmation email: mailhost %s"%value
108         except smtplib.SMTPException, value:
109             return "Couldn't send confirmation email: %s"%value
111 def handle_message(db, message):
112     # handle the subject line
113     m = subject_re.match(message.getheader('subject'))
114     if not m:
115         raise ValueError, 'No [designator] found in subject "%s"'
116     classname = m.group('classname')
117     nodeid = m.group('nodeid')
118     title = m.group('title').strip()
119     subject_args = m.group('args')
120     cl = db.getclass(classname)
121     properties = cl.getprops()
122     props = {}
123     args = m.group('args')
124     if args:
125         for prop in string.split(m.group('args'), ';'):
126             try:
127                 key, value = prop.split('=')
128             except ValueError, message:
129                 raise ValueError, 'Args list not of form [arg=value,value,...;arg=value,value,value..]  (specific exception message was "%s")'%message
130             type =  properties[key]
131             if type.isStringType:
132                 props[key] = value 
133             elif type.isDateType:
134                 props[key] = date.Date(value)
135             elif type.isIntervalType:
136                 props[key] = date.Interval(value)
137             elif type.isLinkType:
138                 props[key] = value
139             elif type.isMultilinkType:
140                 props[key] = value.split(',')
142     # handle the users
143     author = db.uidFromAddress(message.getaddrlist('from')[0])
144     recipients = []
145     for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
146         if recipient[1].strip().lower() == config.ISSUE_TRACKER_EMAIL:
147             continue
148         recipients.append(db.uidFromAddress(recipient))
150     # now handle the body - find the message
151     content_type =  message.gettype()
152     attachments = []
153     if content_type == 'multipart/mixed':
154         boundary = message.getparam('boundary')
155         # skip over the intro to the first boundary
156         part = getPart(message.fp, boundary)
157         content = None
158         while 1:
159             # get the next part
160             part = getPart(message.fp, boundary)
161             if part is None:
162                 break
163             # parse it
164             part.seek(0)
165             submessage = mimetools.Message(part)
166             subtype = submessage.gettype()
167             if subtype == 'text/plain' and not content:
168                 # this one's our content
169                 content = part.read()
170             elif subtype == 'message/rfc822':
171                 i = part.tell()
172                 subsubmess = mimetools.Message(part)
173                 name = subsubmess.getheader('subject')
174                 part.seek(i)
175                 attachments.append((name, 'message/rfc822', part.read()))
176             else:
177                 # try name on Content-Type
178                 name = submessage.getparam('name')
179                 # this is just an attachment
180                 data = part.read()
181                 encoding = submessage.getencoding()
182                 if encoding == 'base64':
183                     data = binascii.a2b_base64(data)
184                 elif encoding == 'quoted-printable':
185                     data = quopri.decode(data)
186                 elif encoding == 'uuencoded':
187                     data = binascii.a2b_uu(data)
188                 attachments.append((name, submessage.gettype(), data))
189         if content is None:
190             raise ValueError, 'No text/plain part found'
192     elif content_type[:10] == 'multipart/':
193         boundary = message.getparam('boundary')
194         # skip over the intro to the first boundary
195         getPart(message.fp, boundary)
196         content = None
197         while 1:
198             # get the next part
199             part = getPart(message.fp, boundary)
200             if part is None:
201                 break
202             # parse it
203             part.seek(0)
204             submessage = mimetools.Message(part)
205             if submessage.gettype() == 'text/plain' and not content:
206                 # this one's our content
207                 content = part.read()
208         if content is None:
209             raise ValueError, 'No text/plain part found'
211     elif content_type != 'text/plain':
212         raise ValueError, 'No text/plain part found'
214     else:
215         content = message.fp.read()
217     # extract out the summary from the message
218     summary = []
219     for line in content.split('\n'):
220         line = line.strip()
221         if summary and not line:
222             break
223         if not line:
224             summary.append('')
225         elif line[0] not in '>|':
226             summary.append(line)
227     summary = '\n'.join(summary)
229     # handle the files
230     files = []
231     for (name, type, data) in attachments:
232         files.append(db.file.create(type=type, name=name, content=data))
234     # now handle the db stuff
235     if nodeid:
236         # If an item designator (class name and id number) is found there, the
237         # newly created "msg" node is added to the "messages" property for
238         # that item, and any new "file" nodes are added to the "files" 
239         # property for the item. 
240         message_id = db.msg.create(author=author, recipients=recipients,
241             date=date.Date('.'), summary=summary, content=content,
242             files=files)
243         messages = cl.get(nodeid, 'messages')
244         messages.append(message_id)
245         props['messages'] = messages
246         apply(cl.set, (nodeid, ), props)
247     else:
248         # If just an item class name is found there, we attempt to create a
249         # new item of that class with its "messages" property initialized to
250         # contain the new "msg" node and its "files" property initialized to
251         # contain any new "file" nodes. 
252         message_id = db.msg.create(author=author, recipients=recipients,
253             date=date.Date('.'), summary=summary, content=content,
254             files=files)
255         if not props.has_key('assignedto'):
256             props['assignedto'] = 1             # "admin"
257         if not props.has_key('priority'):
258             props['priority'] = 1               # "bug-fatal"
259         if not props.has_key('status'):
260             props['status'] = 1                 # "unread"
261         if not props.has_key('title'):
262             props['title'] = title
263         props['messages'] = [message_id]
264         props['nosy'] = recipients[:]
265         props['nosy'].append(author)
266         props['nosy'].sort()
267         nodeid = apply(cl.create, (), props)
269     return 0
271 if __name__ == '__main__':
272     db = roundupdb.openDB(config.DATABASE, 'admin', '1')
273     roundup_mail(db, sys.stdin)
274     db.close()
277 # $Log: not supported by cvs2svn $
278 # Revision 1.2  2001/07/19 05:52:22  anthonybaxter
279 # Added CVS keywords Id and Log to all python files.