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()
276 #
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.
280 #
281 #