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)
268 #
269 # $Log: not supported by cvs2svn $
270 #