8c181aa27c73f4ba798551afb84a22fd269f22f2
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)
319 #
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.
323 #
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 :)
326 #
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.
329 #
330 # Revision 1.3 2001/07/28 00:34:34 richard
331 # Fixed some non-string node ids.
332 #
333 # Revision 1.2 2001/07/22 12:09:32 richard
334 # Final commit of Grande Splite
335 #
336 #
337 # vim: set filetype=python ts=4 sw=4 et si