Skip to content

Commit 0460cab

Browse files
author
Richard Jones
committed
Multipart message class has the getPart method now. Added some tests for it.
1 parent 6127b17 commit 0460cab

File tree

3 files changed

+190
-53
lines changed

3 files changed

+190
-53
lines changed

roundup/mailgw.py

Lines changed: 84 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
'''
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.
2+
An e-mail gateway for Roundup.
93
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.
12+
13+
Summary
14+
-------
1015
The "summary" property on message nodes is taken from the first non-quoting
1116
section in the message body. The message body is divided into sections by
1217
blank lines. Sections where the second and all subsequent lines begin with
1318
a ">" or "|" character are considered "quoting sections". The first line of
1419
the first non-quoting section becomes the summary of the message.
1520
21+
Addresses
22+
---------
1623
All of the addresses in the To: and Cc: headers of the incoming message are
1724
looked up among the user nodes, and the corresponding users are placed in
1825
the "recipients" property on the new "msg" node. The address in the From:
@@ -24,6 +31,8 @@
2431
register an auditor on the "user" class that prevents the creation of user
2532
nodes with no passwords.
2633
34+
Actions
35+
-------
2736
The subject line of the incoming message is examined to determine whether
2837
the message is an attempt to create a new item or to discuss an existing
2938
item. A designator enclosed in square brackets is sought as the first thing
@@ -38,36 +47,44 @@
3847
"msg" node and its "files" property initialized to contain any new "file"
3948
nodes.
4049
50+
Triggers
51+
--------
4152
Both cases may trigger detectors (in the first case we are calling the
4253
set() method to add the message to the item's spool; in the second case we
4354
are calling the create() method to create a new node). If an auditor raises
4455
an exception, the original message is bounced back to the sender with the
4556
explanatory message given in the exception.
4657
47-
$Id: mailgw.py,v 1.3 2001-07-28 00:34:34 richard Exp $
58+
$Id: mailgw.py,v 1.4 2001-07-28 06:43:02 richard Exp $
4859
'''
4960

5061

51-
import string, re, os, mimetools, StringIO, smtplib, socket, binascii, quopri
62+
import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
5263
import traceback
5364
import date
5465

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
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)
7188

7289
subject_re = re.compile(r'(\[?(fwd|re):\s*)*'
7390
r'(\[(?P<classname>[^\d]+)(?P<nodeid>\d+)?\])'
@@ -78,8 +95,15 @@ def __init__(self, db):
7895
self.db = db
7996

8097
def main(self, fp):
98+
''' fp - the file from which to read the Message.
99+
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+
'''
81105
# ok, figure the subject, author, recipients and content-type
82-
message = mimetools.Message(fp)
106+
message = Message(fp)
83107
try:
84108
self.handle_message(message)
85109
except:
@@ -89,7 +113,7 @@ def main(self, fp):
89113
m.append('')
90114
# TODO as attachments?
91115
m.append('---- traceback of failure ----')
92-
s = StringIO.StringIO()
116+
s = cStringIO.StringIO()
93117
import traceback
94118
traceback.print_exc(None, s)
95119
m.append(s.getvalue())
@@ -108,6 +132,10 @@ def main(self, fp):
108132
return "Couldn't send confirmation email: %s"%value
109133

110134
def handle_message(self, message):
135+
''' message - a Message instance
136+
137+
Parse the message as per the module docstring.
138+
'''
111139
# handle the subject line
112140
m = subject_re.match(message.getheader('subject'))
113141
if not m:
@@ -150,60 +178,62 @@ def handle_message(self, message):
150178
content_type = message.gettype()
151179
attachments = []
152180
if content_type == 'multipart/mixed':
153-
boundary = message.getparam('boundary')
154181
# skip over the intro to the first boundary
155-
part = getPart(message.fp, boundary)
182+
part = message.getPart()
156183
content = None
157184
while 1:
158185
# get the next part
159-
part = getPart(message.fp, boundary)
186+
part = message.getPart()
160187
if part is None:
161188
break
162189
# parse it
163-
part.seek(0)
164-
submessage = mimetools.Message(part)
165-
subtype = submessage.gettype()
190+
subtype = part.gettype()
166191
if subtype == 'text/plain' and not content:
167-
# this one's our content
168-
content = part.read()
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()
197+
169198
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()))
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()))
206+
175207
else:
176208
# try name on Content-Type
177-
name = submessage.getparam('name')
209+
name = part.getparam('name')
178210
# this is just an attachment
179-
data = part.read()
180-
encoding = submessage.getencoding()
211+
data = part.fp.read()
212+
encoding = part.getencoding()
181213
if encoding == 'base64':
182214
data = binascii.a2b_base64(data)
183215
elif encoding == 'quoted-printable':
184216
data = quopri.decode(data)
185217
elif encoding == 'uuencoded':
186218
data = binascii.a2b_uu(data)
187-
attachments.append((name, submessage.gettype(), data))
219+
attachments.append((name, part.gettype(), data))
220+
188221
if content is None:
189222
raise ValueError, 'No text/plain part found'
190223

191224
elif content_type[:10] == 'multipart/':
192-
boundary = message.getparam('boundary')
193225
# skip over the intro to the first boundary
194-
getPart(message.fp, boundary)
226+
message.getPart()
195227
content = None
196228
while 1:
197229
# get the next part
198-
part = getPart(message.fp, boundary)
230+
part = message.getPart()
199231
if part is None:
200232
break
201233
# parse it
202-
part.seek(0)
203-
submessage = mimetools.Message(part)
204-
if submessage.gettype() == 'text/plain' and not content:
234+
if part.gettype() == 'text/plain' and not content:
205235
# this one's our content
206-
content = part.read()
236+
content = part.fp.read()
207237
if content is None:
208238
raise ValueError, 'No text/plain part found'
209239

@@ -267,6 +297,9 @@ def handle_message(self, message):
267297

268298
#
269299
# $Log: not supported by cvs2svn $
300+
# Revision 1.3 2001/07/28 00:34:34 richard
301+
# Fixed some non-string node ids.
302+
#
270303
# Revision 1.2 2001/07/22 12:09:32 richard
271304
# Final commit of Grande Splite
272305
#

test/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1-
# $Id: __init__.py,v 1.1 2001-07-27 06:55:07 richard Exp $
1+
# $Id: __init__.py,v 1.2 2001-07-28 06:43:02 richard Exp $
22

33
import unittest
44

5-
import test_dates, test_schema, test_db
5+
import test_dates, test_schema, test_db, test_multipart
66

77
def go():
88
suite = unittest.TestSuite((
99
test_dates.suite(),
1010
test_schema.suite(),
1111
test_db.suite(),
12+
test_multipart.suite(),
1213
))
1314
runner = unittest.TextTestRunner()
1415
runner.run(suite)
1516

1617
#
1718
# $Log: not supported by cvs2svn $
19+
# Revision 1.1 2001/07/27 06:55:07 richard
20+
# moving tests -> test
21+
#
1822
# Revision 1.3 2001/07/25 04:34:31 richard
1923
# Added id and log to tests files...
2024
#

test/test_multipart.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# $Id: test_multipart.py,v 1.1 2001-07-28 06:43:02 richard Exp $
2+
3+
import unittest, cStringIO
4+
5+
from roundup.mailgw import Message
6+
7+
class MultipartTestCase(unittest.TestCase):
8+
def setUp(self):
9+
self.fp = cStringIO.StringIO()
10+
w = self.fp.write
11+
w('Content-Type: multipart/mixed; boundary="foo"\r\n\r\n')
12+
w('This is a multipart message. Ignore this bit.\r\n')
13+
w('--foo\r\n')
14+
15+
w('Content-Type: text/plain\r\n\r\n')
16+
w('Hello, world!\r\n')
17+
w('\r\n')
18+
w('Blah blah\r\n')
19+
w('foo\r\n')
20+
w('-foo\r\n')
21+
w('--foo\r\n')
22+
23+
w('Content-Type: multipart/alternative; boundary="bar"\r\n\r\n')
24+
w('This is a multipart message. Ignore this bit.\r\n')
25+
w('--bar\r\n')
26+
27+
w('Content-Type: text/plain\r\n\r\n')
28+
w('Hello, world!\r\n')
29+
w('\r\n')
30+
w('Blah blah\r\n')
31+
w('--bar\r\n')
32+
33+
w('Content-Type: text/html\r\n\r\n')
34+
w('<b>Hello, world!</b>\r\n')
35+
w('--bar--\r\n')
36+
w('--foo\r\n')
37+
38+
w('Content-Type: text/plain\r\n\r\n')
39+
w('Last bit\n')
40+
w('--foo--\r\n')
41+
self.fp.seek(0)
42+
43+
def testMultipart(self):
44+
m = Message(self.fp)
45+
self.assert_(m is not None)
46+
47+
# skip the first bit
48+
p = m.getPart()
49+
self.assert_(p is not None)
50+
self.assertEqual(p.fp.read(),
51+
'This is a multipart message. Ignore this bit.\r\n')
52+
53+
# first text/plain
54+
p = m.getPart()
55+
self.assert_(p is not None)
56+
self.assertEqual(p.gettype(), 'text/plain')
57+
self.assertEqual(p.fp.read(),
58+
'Hello, world!\r\n\r\nBlah blah\r\nfoo\r\n-foo\r\n')
59+
60+
# sub-multipart
61+
p = m.getPart()
62+
self.assert_(p is not None)
63+
self.assertEqual(p.gettype(), 'multipart/alternative')
64+
65+
# sub-multipart text/plain
66+
q = p.getPart()
67+
self.assert_(q is not None)
68+
q = p.getPart()
69+
self.assert_(q is not None)
70+
self.assertEqual(q.gettype(), 'text/plain')
71+
self.assertEqual(q.fp.read(), 'Hello, world!\r\n\r\nBlah blah\r\n')
72+
73+
# sub-multipart text/html
74+
q = p.getPart()
75+
self.assert_(q is not None)
76+
self.assertEqual(q.gettype(), 'text/html')
77+
self.assertEqual(q.fp.read(), '<b>Hello, world!</b>\r\n')
78+
79+
# sub-multipart end
80+
q = p.getPart()
81+
self.assert_(q is None)
82+
83+
# final text/plain
84+
p = m.getPart()
85+
self.assert_(p is not None)
86+
self.assertEqual(p.gettype(), 'text/plain')
87+
self.assertEqual(p.fp.read(),
88+
'Last bit\n')
89+
90+
# end
91+
p = m.getPart()
92+
self.assert_(p is None)
93+
94+
def suite():
95+
return unittest.makeSuite(MultipartTestCase, 'test')
96+
97+
98+
#
99+
# $Log: not supported by cvs2svn $
100+
#

0 commit comments

Comments
 (0)