Skip to content

Commit e474fe4

Browse files
author
Ralf Schlatterbeck
committed
Mail improvements:
- Implement new config option in mail-section "ignore_alternatives" to ignore alternatives in a multipart/alternative mail. The *last* text/plain part of the *first* multipart/alternative is used as the message, if ignore_alternatives is set all other alternative parts of the first multipart/alternative that contained a text/plain part are ignored. Other multipart/alternative or other multipart are attached as before. This fixes [SF#959811] "Multipart/alternative handling considered bad". Note that this also changes which text/plain part is attached as the message if there are several text/plain parts in a multipart: Previously the *first* text/plain would be attached. Now we attach the *last* one, this is more in line with rfc 2046, sec. 5.1.4. according to Philipp Gortan. - Fix bug in attachment of text parts: If there are multiple text/plain parts in a nested multipart, the previous code would attach the multipart serialisation instead of the text/plain serialisation as a file to the issue in some cases. - Add regression tests for the new config-option and bug-fixes above.
1 parent 966f6ec commit e474fe4

File tree

4 files changed

+159
-18
lines changed

4 files changed

+159
-18
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ are given with the most recent entry first.
44
2007-11-?? 1.4.1
55
Fixed:
66
- Removed some metakit references
7+
- New config option in mail section: ignore_alternatives allows to
8+
ignore alternatives besides the text/plain part used for the content
9+
of a message in multipart/alternative attachments.
710

811

912
2007-11-04 1.4.0

roundup/configuration.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Roundup Issue Tracker configuration support
22
#
3-
# $Id: configuration.py,v 1.49 2007-09-26 03:20:21 jpend Exp $
3+
# $Id: configuration.py,v 1.50 2007-11-14 14:57:47 schlatterbeck Exp $
44
#
55
__docformat__ = "restructuredtext"
66

@@ -716,6 +716,12 @@ def str2value(self, value):
716716
"Regular expression matching end of line."),
717717
(RegExpOption, "blankline_re", r"[\r\n]+\s*[\r\n]+",
718718
"Regular expression matching a blank line."),
719+
(BooleanOption, "ignore_alternatives", "no",
720+
"When parsing incoming mails, roundup uses the first\n"
721+
"text/plain part it finds. If this part is inside a\n"
722+
"multipart/alternative, and this option is set, all other\n"
723+
"parts of the multipart/alternative are ignored. The default\n"
724+
"is to keep all parts and attach them to the issue."),
719725
), "Roundup Mail Gateway options"),
720726
("pgp", (
721727
(BooleanOption, "enable", "no",

roundup/mailgw.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class node. Any parts of other types are each stored in separate files
7373
an exception, the original message is bounced back to the sender with the
7474
explanatory message given in the exception.
7575
76-
$Id: mailgw.py,v 1.192 2007-09-26 03:20:21 jpend Exp $
76+
$Id: mailgw.py,v 1.193 2007-11-14 14:57:47 schlatterbeck Exp $
7777
"""
7878
__docformat__ = 'restructuredtext'
7979

@@ -346,26 +346,49 @@ def getbody(self):
346346
# multipart/form-data:
347347
# For web forms only.
348348

349-
def extract_content(self, parent_type=None):
350-
"""Extract the body and the attachments recursively."""
349+
def extract_content(self, parent_type=None, ignore_alternatives = False):
350+
"""Extract the body and the attachments recursively.
351+
352+
If the content is hidden inside a multipart/alternative part,
353+
we use the *last* text/plain part of the *first*
354+
multipart/alternative in the whole message.
355+
"""
351356
content_type = self.gettype()
352357
content = None
353358
attachments = []
354359

355360
if content_type == 'text/plain':
356361
content = self.getbody()
357362
elif content_type[:10] == 'multipart/':
363+
content_found = bool (content)
364+
ig = ignore_alternatives and not content_found
358365
for part in self.getparts():
359-
new_content, new_attach = part.extract_content(content_type)
366+
new_content, new_attach = part.extract_content(content_type,
367+
not content and ig)
360368

361369
# If we haven't found a text/plain part yet, take this one,
362370
# otherwise make it an attachment.
363371
if not content:
364372
content = new_content
373+
cpart = part
365374
elif new_content:
366-
attachments.append(part.as_attachment())
375+
if content_found or content_type != 'multipart/alternative':
376+
attachments.append(part.text_as_attachment())
377+
else:
378+
# if we have found a text/plain in the current
379+
# multipart/alternative and find another one, we
380+
# use the first as an attachment (if configured)
381+
# and use the second one because rfc 2046, sec.
382+
# 5.1.4. specifies that later parts are better
383+
# (thanks to Philipp Gortan for pointing this
384+
# out)
385+
attachments.append(cpart.text_as_attachment())
386+
content = new_content
387+
cpart = part
367388

368389
attachments.extend(new_attach)
390+
if ig and content_type == 'multipart/alternative' and content:
391+
attachments = []
369392
elif (parent_type == 'multipart/signed' and
370393
content_type == 'application/pgp-signature'):
371394
# ignore it so it won't be saved as an attachment
@@ -374,6 +397,20 @@ def extract_content(self, parent_type=None):
374397
attachments.append(self.as_attachment())
375398
return content, attachments
376399

400+
def text_as_attachment(self):
401+
"""Return first text/plain part as Message"""
402+
if not self.gettype().startswith ('multipart/'):
403+
return self.as_attachment()
404+
for part in self.getparts():
405+
content_type = part.gettype()
406+
if content_type == 'text/plain':
407+
return part.as_attachment()
408+
elif content_type.startswith ('multipart/'):
409+
p = part.text_as_attachment()
410+
if p:
411+
return p
412+
return None
413+
377414
def as_attachment(self):
378415
"""Return this message as an attachment."""
379416
return (self.getname(), self.gettype(), self.getbody())
@@ -1204,7 +1241,8 @@ def pgp_role():
12041241
This tracker has been configured to require all email be PGP signed or
12051242
encrypted.""")
12061243
# now handle the body - find the message
1207-
content, attachments = message.extract_content()
1244+
ig = self.instance.config.MAILGW_IGNORE_ALTERNATIVES
1245+
content, attachments = message.extract_content(ignore_alternatives = ig)
12081246
if content is None:
12091247
raise MailUsageError, _("""
12101248
Roundup requires the submission to be plain text. The message parser could

test/test_mailgw.py

Lines changed: 105 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# but WITHOUT ANY WARRANTY; without even the implied warranty of
99
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
1010
#
11-
# $Id: test_mailgw.py,v 1.90 2007-11-04 06:11:19 richard Exp $
11+
# $Id: test_mailgw.py,v 1.91 2007-11-14 14:57:47 schlatterbeck Exp $
1212

1313
# TODO: test bcc
1414

@@ -340,14 +340,106 @@ def testNewIssueNoAuthorEmail(self):
340340
_______________________________________________________________________
341341
''')
342342

343-
# BUG
344-
# def testMultipart(self):
345-
# '''With more than one part'''
346-
# see MultipartEnc tests: but if there is more than one part
347-
# we return a multipart/mixed and the boundary contains
348-
# the ip address of the test machine.
343+
multipart_msg = '''Content-Type: text/plain;
344+
charset="iso-8859-1"
345+
From: mary <[email protected]>
346+
347+
Message-Id: <followup_dummy_id>
348+
In-Reply-To: <dummy_test_message_id>
349+
Subject: [issue1] Testing...
350+
Content-Type: multipart/mixed; boundary="bxyzzy"
351+
Content-Disposition: inline
352+
353+
354+
--bxyzzy
355+
Content-Type: multipart/alternative; boundary="bCsyhTFzCvuiizWE"
356+
Content-Disposition: inline
357+
358+
--bCsyhTFzCvuiizWE
359+
Content-Type: text/plain; charset=us-ascii
360+
Content-Disposition: inline
361+
362+
test attachment first text/plain
363+
364+
--bCsyhTFzCvuiizWE
365+
Content-Type: application/octet-stream
366+
Content-Disposition: attachment; filename="first.dvi"
367+
Content-Transfer-Encoding: base64
368+
369+
SnVzdCBhIHRlc3QgAQo=
370+
371+
--bCsyhTFzCvuiizWE
372+
Content-Type: text/plain; charset=us-ascii
373+
Content-Disposition: inline
374+
375+
test attachment second text/plain
349376
350-
# BUG should test some binary attamchent too.
377+
--bCsyhTFzCvuiizWE
378+
Content-Type: text/html
379+
Content-Disposition: inline
380+
381+
<html>
382+
to be ignored.
383+
</html>
384+
385+
--bCsyhTFzCvuiizWE--
386+
387+
--bxyzzy
388+
Content-Type: multipart/alternative; boundary="bCsyhTFzCvuiizWF"
389+
Content-Disposition: inline
390+
391+
--bCsyhTFzCvuiizWF
392+
Content-Type: text/plain; charset=us-ascii
393+
Content-Disposition: inline
394+
395+
test attachment third text/plain
396+
397+
--bCsyhTFzCvuiizWF
398+
Content-Type: application/octet-stream
399+
Content-Disposition: attachment; filename="second.dvi"
400+
Content-Transfer-Encoding: base64
401+
402+
SnVzdCBhIHRlc3QK
403+
404+
--bCsyhTFzCvuiizWF--
405+
406+
--bxyzzy--
407+
'''
408+
409+
def testMultipartKeepAlternatives(self):
410+
self.doNewIssue()
411+
self._handle_mail(self.multipart_msg)
412+
messages = self.db.issue.get('1', 'messages')
413+
messages.sort()
414+
msg = self.db.msg.getnode (messages[-1])
415+
assert(len(msg.files) == 5)
416+
names = {0 : 'first.dvi', 4 : 'second.dvi'}
417+
content = {3 : 'test attachment third text/plain\n',
418+
4 : 'Just a test\n'}
419+
for n, id in enumerate (msg.files):
420+
f = self.db.file.getnode (id)
421+
self.assertEqual(f.name, names.get (n, 'unnamed'))
422+
if n in content :
423+
self.assertEqual(f.content, content [n])
424+
self.assertEqual(msg.content, 'test attachment second text/plain')
425+
426+
def testMultipartDropAlternatives(self):
427+
self.doNewIssue()
428+
self.db.config.MAILGW_IGNORE_ALTERNATIVES = True
429+
self._handle_mail(self.multipart_msg)
430+
messages = self.db.issue.get('1', 'messages')
431+
messages.sort()
432+
msg = self.db.msg.getnode (messages[-1])
433+
assert(len(msg.files) == 2)
434+
names = {1 : 'second.dvi'}
435+
content = {0 : 'test attachment third text/plain\n',
436+
1 : 'Just a test\n'}
437+
for n, id in enumerate (msg.files):
438+
f = self.db.file.getnode (id)
439+
self.assertEqual(f.name, names.get (n, 'unnamed'))
440+
if n in content :
441+
self.assertEqual(f.content, content [n])
442+
self.assertEqual(msg.content, 'test attachment second text/plain')
351443

352444
def testSimpleFollowup(self):
353445
self.doNewIssue()
@@ -1068,15 +1160,17 @@ def testContentDisposition(self):
10681160
--bCsyhTFzCvuiizWE
10691161
Content-Type: application/octet-stream
10701162
Content-Disposition: attachment; filename="main.dvi"
1163+
Content-Transfer-Encoding: base64
10711164
1072-
xxxxxx
1165+
SnVzdCBhIHRlc3QgAQo=
10731166
10741167
--bCsyhTFzCvuiizWE--
10751168
''')
10761169
messages = self.db.issue.get('1', 'messages')
10771170
messages.sort()
1078-
file = self.db.msg.get(messages[-1], 'files')[0]
1079-
self.assertEqual(self.db.file.get(file, 'name'), 'main.dvi')
1171+
file = self.db.file.getnode (self.db.msg.get(messages[-1], 'files')[0])
1172+
self.assertEqual(file.name, 'main.dvi')
1173+
self.assertEqual(file.content, 'Just a test \001')
10801174

10811175
def testFollowupStupidQuoting(self):
10821176
self.doNewIssue()

0 commit comments

Comments
 (0)