Code

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