1 # Copyright (C) 2012-2023 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <https://www.gnu.org/licenses/>.
18 """Test the mime_delete handler."""
27 from contextlib
import contextmanager
, ExitStack
28 from importlib
.resources
import open_binary
as resource_open
, read_text
29 from io
import StringIO
30 from mailman
.app
.lifecycle
import create_list
31 from mailman
.config
import config
32 from mailman
.handlers
import mime_delete
33 from mailman
.interfaces
.action
import FilterAction
34 from mailman
.interfaces
.member
import MemberRole
35 from mailman
.interfaces
.pipeline
import DiscardMessage
, RejectMessage
36 from mailman
.interfaces
.usermanager
import IUserManager
37 from mailman
.testing
.helpers
import (
41 specialized_message_from_string
as mfs
,
43 from mailman
.testing
.layers
import ConfigLayer
44 from unittest
.mock
import patch
45 from zope
.component
import getUtility
49 def dummy_script(arg
=''):
53 if arg
== 'non-ascii':
56 if arg
== 'scripterr':
60 with
ExitStack() as resources
:
61 tempdir
= tempfile
.mkdtemp()
62 resources
.callback(shutil
.rmtree
, tempdir
)
63 filter_path
= os
.path
.join(tempdir
, 'filter.py')
64 if arg
in ('noperm', 'nonexist'):
66 with
open(filter_path
, 'w', encoding
='utf-8') as fp
:
71 print('Converted text/html to text/plain{}')
72 print('Filename:', sys.argv[1])
73 print(open(sys.argv[1]).readlines()[0])
74 """.format(non_ascii
), file=fp
)
75 config
.push('dummy script', """\
77 html_to_plain_text_command = {exe} {script} {extra} $filename
78 filter_report = {report}
79 """.format(exe
=exe
, script
=filter_path
, extra
=extra
, report
=report
))
81 resources
.callback(config
.pop
, 'dummy script')
83 os
.rename(filter_path
, filter_path
+ 'xxx')
85 os
.chmod(filter_path
, 0o644)
89 class TestDispose(unittest
.TestCase
):
90 """Test the mime_delete handler."""
96 self
._mlist
= create_list('test@example.com')
98 From: anne@example.com
100 Subject: A disposable message
104 config
.push('dispose', """
106 site_owner: noreply@example.com
108 self
.addCleanup(config
.pop
, 'dispose')
110 def test_dispose_discard(self
):
111 self
._mlist
.filter_action
= FilterAction
.discard
112 with self
.assertRaises(DiscardMessage
) as cm
:
113 mime_delete
.dispose(self
._mlist
, self
._msg
, {}, 'discarding')
114 self
.assertEqual(cm
.exception
.message
, 'discarding')
115 # There should be no messages in the 'bad' queue.
116 get_queue_messages('bad', expected_count
=0)
118 def test_dispose_discard_no_spurious_log(self
):
119 self
._mlist
.filter_action
= FilterAction
.discard
120 mark
= LogFileMark('mailman.error')
121 with self
.assertRaises(DiscardMessage
):
122 mime_delete
.dispose(self
._mlist
, self
._msg
, {}, 'discarding')
123 self
.assertEqual(mark
.readline(), '')
125 def test_dispose_bounce(self
):
126 self
._mlist
.filter_action
= FilterAction
.reject
127 with self
.assertRaises(RejectMessage
) as cm
:
128 mime_delete
.dispose(self
._mlist
, self
._msg
, {}, 'rejecting')
129 self
.assertEqual(cm
.exception
.message
, 'rejecting')
130 # There should be no messages in the 'bad' queue.
131 get_queue_messages('bad', expected_count
=0)
133 def test_dispose_forward(self
):
134 # The disposed message gets forwarded to the list administrators. So
135 # first add an owner and a moderator.
136 user_manager
= getUtility(IUserManager
)
137 anne
= user_manager
.create_address('anne@example.com')
138 bart
= user_manager
.create_address('bart@example.com')
139 self
._mlist
.subscribe(anne
, MemberRole
.owner
)
140 self
._mlist
.subscribe(bart
, MemberRole
.moderator
)
141 # Now set the filter action and dispose the message.
142 self
._mlist
.filter_action
= FilterAction
.forward
143 with self
.assertRaises(DiscardMessage
) as cm
:
144 mime_delete
.dispose(self
._mlist
, self
._msg
, {}, 'forwarding')
145 self
.assertEqual(cm
.exception
.message
, 'forwarding')
146 # There should now be a multipart message in the virgin queue destined
147 # for the mailing list owners.
148 items
= get_queue_messages('virgin', expected_count
=1)
149 message
= items
[0].msg
150 self
.assertEqual(message
.get_content_type(), 'multipart/mixed')
151 # Anne and Bart should be recipients of the message, but it will look
152 # like the message is going to the list owners.
153 self
.assertEqual(message
['to'], 'test-owner@example.com')
154 self
.assertEqual(message
.recipients
,
155 set(['anne@example.com', 'bart@example.com']))
156 # The list owner should be the sender.
157 self
.assertEqual(message
['from'], 'noreply@example.com')
158 self
.assertEqual(message
['subject'],
159 'Content filter message notification')
160 # The body of the first part provides the moderators some details.
161 part0
= message
.get_payload(0)
162 self
.assertEqual(part0
.get_content_type(), 'text/plain')
163 self
.assertMultiLineEqual(part0
.get_payload(), """\
164 The attached message matched the Test mailing list's content
165 filtering rules and was prevented from being forwarded on to the list
166 membership. You are receiving the only remaining copy of the discarded
170 # The second part is the container for the original message.
171 part1
= message
.get_payload(1)
172 self
.assertEqual(part1
.get_content_type(), 'message/rfc822')
173 # And the first part of *that* message will be the original message.
174 original
= part1
.get_payload(0)
175 self
.assertEqual(original
['subject'], 'A disposable message')
176 self
.assertEqual(original
['message-id'], '<ant>')
178 @configuration('mailman', filtered_messages_are_preservable
='no')
179 def test_dispose_non_preservable(self
):
180 # Two actions can happen here, depending on a site-wide setting. If
181 # the site owner has indicated that filtered messages cannot be
182 # preserved, then this is the same as discarding them.
183 self
._mlist
.filter_action
= FilterAction
.preserve
184 with self
.assertRaises(DiscardMessage
) as cm
:
185 mime_delete
.dispose(self
._mlist
, self
._msg
, {}, 'not preserved')
186 self
.assertEqual(cm
.exception
.message
, 'not preserved')
187 # There should be no messages in the 'bad' queue.
188 get_queue_messages('bad', expected_count
=0)
190 @configuration('mailman', filtered_messages_are_preservable
='yes')
191 def test_dispose_preservable(self
):
192 # Two actions can happen here, depending on a site-wide setting. If
193 # the site owner has indicated that filtered messages can be
194 # preserved, then this is similar to discarding the message except
195 # that a copy is preserved in the 'bad' queue.
196 self
._mlist
.filter_action
= FilterAction
.preserve
197 with self
.assertRaises(DiscardMessage
) as cm
:
198 mime_delete
.dispose(self
._mlist
, self
._msg
, {}, 'preserved')
199 self
.assertEqual(cm
.exception
.message
, 'preserved')
200 # There should be a message in the 'bad' queue.
201 items
= get_queue_messages('bad', expected_count
=1)
202 message
= items
[0].msg
203 self
.assertEqual(message
['subject'], 'A disposable message')
204 self
.assertEqual(message
['message-id'], '<ant>')
206 @configuration('mailman', filtered_messages_are_preservable
='yes')
207 def test_preserved_message_has_content(self
):
209 From: anne@example.com
211 Subject: Testing preserved message has content
214 Content-Type: multipart/mixed; boundary="AAAA"
217 Content-Type: text/bogus; charset="utf-8"
218 Content-Transfer-Encoding: quoted-printable
220 Let=E2=80=99s also consider
223 Content-Type: text/other; charset="utf-8"
224 Content-Transfer-Encoding: quoted-printable
226 Let=E2=80=99s also consider
230 self
._mlist
.filter_content
= True
231 self
._mlist
.filter_action
= FilterAction
.preserve
232 self
._mlist
.pass_types
= ['multipart', 'text/plain']
233 with self
.assertRaises(DiscardMessage
) as cm
:
234 mime_delete
.process(self
._mlist
, msg
, {})
235 self
.assertEqual(cm
.exception
.message
,
236 'After content filtering, the message was empty')
237 # There should be a message in the 'bad' queue.
238 items
= get_queue_messages('bad', expected_count
=1)
239 message
= items
[0].msg
240 self
.assertEqual(message
['subject'],
241 'Testing preserved message has content')
242 self
.assertEqual(message
['message-id'], '<ant>')
243 # The message should be multipart with two subparts.
244 self
.assertEqual(len(message
.get_payload()), 2)
246 def test_bad_action(self
):
247 # This should never happen, but what if it does?
248 # FilterAction.accept, FilterAction.hold, and FilterAction.defer are
249 # not valid. They are treated as discard actions, but the problem is
251 for action
in (FilterAction
.accept
,
254 self
._mlist
.filter_action
= action
255 mark
= LogFileMark('mailman.error')
256 with self
.assertRaises(DiscardMessage
) as cm
:
257 mime_delete
.dispose(self
._mlist
, self
._msg
, {}, 'bad action')
258 self
.assertEqual(cm
.exception
.message
, 'bad action')
259 line
= mark
.readline()[:-1]
260 self
.assertTrue(line
.endswith(
261 'test@example.com invalid FilterAction: {}. '
262 'Treating as discard'.format(action
.name
)))
265 class TestHTMLFilter(unittest
.TestCase
):
266 """Test the conversion of HTML to plaintext."""
271 self
._mlist
= create_list('test@example.com')
272 self
._mlist
.convert_html_to_plaintext
= True
273 self
._mlist
.filter_content
= True
275 def test_convert_html_to_plaintext(self
):
276 # Converting to plain text calls a command line script.
278 From: aperson@example.com
279 Content-Type: text/html
285 process
= config
.handlers
['mime-delete'].process
287 process(self
._mlist
, msg
, {})
288 self
.assertEqual(msg
.get_content_type(), 'text/plain')
290 msg
['x-content-filtered-by'].startswith('Mailman/MimeDel'))
291 payload_lines
= msg
.get_payload().splitlines()
292 self
.assertEqual(payload_lines
[0], 'Converted text/html to text/plain')
294 def test_convert_html_to_plaintext_base64(self
):
295 # Converting to plain text calls a command line script with decoded
298 From: aperson@example.com
299 Content-Type: text/html
300 Content-Transfer-Encoding: base64
303 PGh0bWw+PGhlYWQ+PC9oZWFkPgo8Ym9keT48L2JvZHk+PC9odG1sPgo=
305 process
= config
.handlers
['mime-delete'].process
307 process(self
._mlist
, msg
, {})
308 self
.assertEqual(msg
.get_content_type(), 'text/plain')
310 msg
['x-content-filtered-by'].startswith('Mailman/MimeDel'))
311 payload_lines
= msg
.get_payload().splitlines()
312 self
.assertEqual(payload_lines
[0], 'Converted text/html to text/plain')
313 self
.assertEqual(payload_lines
[2], '<html><head></head>')
315 def test_convert_html_to_plaintext_encodes_new_payload(self
):
316 # Test that the converted payload with non-ascii is encoded.
318 From: aperson@example.com
319 Content-Type: text/html; charset=utf-8
320 Content-Transfer-Encoding: base64
323 Q29udmVydGVkIHRleHQvaHRtbCB0byB0ZXh0L3BsYWlu4oCYLi4u4oCZCg==
325 process
= config
.handlers
['mime-delete'].process
326 with
dummy_script('non-ascii'):
327 process(self
._mlist
, msg
, {})
328 self
.assertEqual(msg
['content-type'], 'text/plain; charset="utf-8"')
329 self
.assertEqual(msg
['content-transfer-encoding'], 'base64')
331 msg
['x-content-filtered-by'].startswith('Mailman/MimeDel'))
333 msg
.get_payload(decode
=True).decode('utf-8').splitlines())
334 self
.assertEqual(payload_lines
[0],
335 'Converted text/html to text/plain‘...’')
336 self
.assertTrue(payload_lines
[1].startswith('Filename'))
338 def test_convert_html_to_plaintext_error_return(self
):
339 # Calling a script which returns an error status is properly logged.
341 From: aperson@example.com
342 Content-Type: text/html
348 process
= config
.handlers
['mime-delete'].process
349 mark
= LogFileMark('mailman.error')
350 with
dummy_script('scripterr'):
351 process(self
._mlist
, msg
, {})
352 line
= mark
.readline()[:-1]
353 self
.assertTrue(line
.endswith('HTML -> text/plain command error'))
354 self
.assertEqual(msg
.get_content_type(), 'text/html')
355 self
.assertIsNone(msg
['x-content-filtered-by'])
356 payload_lines
= msg
.get_payload().splitlines()
357 self
.assertEqual(payload_lines
[0], '<html><head></head>')
359 def test_missing_html_to_plain_text_command(self
):
360 # Calling a missing html_to_plain_text_command is properly logged.
362 From: aperson@example.com
363 Content-Type: text/html
369 process
= config
.handlers
['mime-delete'].process
370 mark
= LogFileMark('mailman.error')
371 with
dummy_script('nonexist'):
372 process(self
._mlist
, msg
, {})
373 line
= mark
.readline()[:-1]
374 self
.assertTrue(line
.endswith('HTML -> text/plain command error'))
375 self
.assertEqual(msg
.get_content_type(), 'text/html')
376 self
.assertIsNone(msg
['x-content-filtered-by'])
377 payload_lines
= msg
.get_payload().splitlines()
378 self
.assertEqual(payload_lines
[0], '<html><head></head>')
380 def test_no_permission_html_to_plain_text_command(self
):
381 # Calling an html_to_plain_text_command without permission is
384 From: aperson@example.com
385 Content-Type: text/html
391 process
= config
.handlers
['mime-delete'].process
392 mark
= LogFileMark('mailman.error')
393 with
dummy_script('noperm'):
394 process(self
._mlist
, msg
, {})
395 line
= mark
.readline()[:-1]
396 self
.assertTrue(line
.endswith('HTML -> text/plain command error'))
397 self
.assertEqual(msg
.get_content_type(), 'text/html')
398 self
.assertIsNone(msg
['x-content-filtered-by'])
399 payload_lines
= msg
.get_payload().splitlines()
400 self
.assertEqual(payload_lines
[0], '<html><head></head>')
402 def test_html_part_with_non_ascii(self
):
403 # Ensure we can convert HTML to plain text in an HTML sub-part which
404 # contains non-ascii.
406 'mailman.handlers.tests.data',
407 'html_to_plain.eml') as fp
:
408 msg
= email
.message_from_binary_file(fp
)
409 process
= config
.handlers
['mime-delete'].process
411 process(self
._mlist
, msg
, {})
412 part
= msg
.get_payload(1)
413 cset
= part
.get_content_charset('us-ascii')
414 text
= part
.get_payload(decode
=True).decode(cset
).splitlines()
415 self
.assertEqual(text
[0], 'Converted text/html to text/plain')
416 self
.assertEqual(text
[2], 'Um frühere Nachrichten')
419 class TestMiscellaneous(unittest
.TestCase
):
420 """Test various miscellaneous filtering actions."""
426 self
._mlist
= create_list('test@example.com')
427 self
._mlist
.collapse_alternatives
= True
428 self
._mlist
.filter_content
= True
429 self
._mlist
.filter_extensions
= ['xlsx']
431 def test_collapse_alternatives(self
):
433 'mailman.handlers.tests.data',
434 'collapse_alternatives.eml') as fp
:
435 msg
= email
.message_from_binary_file(fp
)
436 process
= config
.handlers
['mime-delete'].process
437 process(self
._mlist
, msg
, {})
438 structure
= StringIO()
439 email
.iterators
._structure
(msg
, fp
=structure
)
440 self
.assertEqual(structure
.getvalue(), """\
445 application/pgp-signature
448 def test_collapse_alternatives_non_ascii(self
):
449 # Ensure we can flatten as bytes a message whose non-ascii payload
452 'mailman.handlers.tests.data',
453 'c_a_non_ascii.eml') as fp
:
454 msg
= email
.message_from_binary_file(fp
)
455 process
= config
.handlers
['mime-delete'].process
456 process(self
._mlist
, msg
, {})
457 self
.assertFalse(msg
.is_multipart())
458 self
.assertEqual(msg
.get_payload(decode
=True),
459 b
'Body with non-ascii can\xe2\x80\x99t see '
460 b
'won\xe2\x80\x99t know\n')
461 # Ensure we can flatten it.
462 dummy
= msg
.as_bytes() # noqa: F841
464 def test_collapse_alternatives_non_ascii_encoded(self
):
466 From: anne@example.com
468 Subject: Testing mpa with transfer encoded subparts
471 Content-Type: multipart/alternative; boundary="AAAA"
474 Content-Type: text/plain; charset="utf-8"
475 Content-Transfer-Encoding: quoted-printable
477 Let=E2=80=99s also consider
480 Content-Type: text/html; charset="utf-8"
481 Content-Transfer-Encoding: quoted-printable
483 Let=E2=80=99s also consider
487 process
= config
.handlers
['mime-delete'].process
488 process(self
._mlist
, msg
, {})
489 self
.assertFalse(msg
.is_multipart())
490 self
.assertEqual(msg
.get_payload(decode
=True),
491 b
'Let\xe2\x80\x99s also consider\n')
492 # Ensure we can flatten it.
493 dummy
= msg
.as_bytes() # noqa: F841
495 def test_reset_payload_multipart(self
):
497 From: anne@example.com
499 Subject: Testing mpa with multipart subparts
502 Content-Type: multipart/alternative; boundary="AAAA"
505 Content-Type: multipart/mixed; boundary="BBBB"
508 Content-Type: text/plain
513 Content-Type: text/plain
520 Content-Type: multipart/mixed; boundary="CCCC"
523 Content-Type: text/html
528 Content-Type: text/html
536 process
= config
.handlers
['mime-delete'].process
537 process(self
._mlist
, msg
, {})
538 self
.assertTrue(msg
.is_multipart())
539 self
.assertEqual(msg
.get_content_type(), 'multipart/mixed')
540 self
.assertEqual(len(msg
.get_payload()), 2)
541 self
.assertEqual(msg
.get_payload(0).get_payload(), 'Part 1\n')
542 self
.assertEqual(msg
.get_payload(1).get_payload(), 'Part 2\n')
544 def test_msg_rfc822(self
):
546 'mailman.handlers.tests.data', 'msg_rfc822.eml') as fp
:
547 msg
= email
.message_from_binary_file(fp
)
548 process
= config
.handlers
['mime-delete'].process
549 # Mock this so that the X-Content-Filtered-By header isn't sensitive to
550 # Mailman version bumps.
551 with
patch('mailman.handlers.mime_delete.VERSION', '123'):
552 expected_msg
= read_text(
553 'mailman.handlers.tests.data', 'msg_rfc822_out.eml')
554 process(self
._mlist
, msg
, {})
555 self
.assertEqual(msg
.as_string(), expected_msg
)
557 def test_mixed_case_ext_and_recast(self
):
559 From: anne@example.com
561 Subject: Testing mixed extension
564 Content-Type: multipart/mixed; boundary="AAAA"
567 Content-Type: text/plain; charset="utf-8"
572 Content-Type: application/octet-stream; name="test.xlsX"
573 Content-Disposition: attachment; filename="test.xlsX"
579 process
= config
.handlers
['mime-delete'].process
580 process(self
._mlist
, msg
, {})
581 self
.assertEqual(msg
['content-type'], 'text/plain; charset="utf-8"')
582 self
.assertEqual(msg
.get_payload(decode
=True), b
"""\
586 def test_report(self
):
587 # Hit all the pass and filter conditions for reporting
588 self
._mlist
.pass_extensions
= ['txt']
589 self
._mlist
.filter_types
= ['image']
590 self
._mlist
.pass_types
= ['text', 'application', 'multipart']
592 From: anne@example.com
594 Subject: Testing mixed extension
597 Content-Type: multipart/mixed; boundary="AAAA"
600 Content-Type: multipart/alternative; boundary="BBBB"
603 Content-Type: text/plain; charset="utf-8"
608 Content-Type: text/html; charset="utf-8"
614 Content-Type: application/octet-stream; name="test.xlsX"
615 Content-Disposition: attachment; filename="test.xlsX"
620 Content-Type: application/octet-stream; name="test.exe"
621 Content-Disposition: attachment; filename="test.exe"
626 Content-Type: image/jpeg; name="My_image"
627 Content-Disposition: inline; filename="My_image"
632 Content-Type: video/mp4; name="My_video"
633 Content-Disposition: inline; filename="My_video"
639 process
= config
.handlers
['mime-delete'].process
640 with
dummy_script('report'):
641 process(self
._mlist
, msg
, {})
642 self
.assertEqual(msg
['content-type'], 'text/plain; charset="utf-8"')
643 self
.assertEqual(msg
.get_payload(decode
=True), b
"""\
646 ___________________________________________
647 Mailman's content filtering has removed the
648 following MIME parts from this message.
650 Content-Type: application/octet-stream
653 Content-Type: application/octet-stream
656 Content-Type: image/jpeg
659 Content-Type: video/mp4
662 Replaced multipart/alternative part with first alternative.
665 def test_report_mixed(self
):
666 # Hit all the pass and filter conditions for reporting
667 self
._mlist
.pass_extensions
= ['txt']
668 self
._mlist
.pass_types
= ['text', 'application', 'multipart', 'image']
670 From: anne@example.com
672 Subject: Testing mixed extension
675 Content-Type: multipart/mixed; boundary="AAAA"
678 Content-Type: multipart/alternative; boundary="BBBB"
681 Content-Type: text/plain; charset="utf-8"
686 Content-Type: text/html; charset="utf-8"
692 Content-Type: application/octet-stream; name="test.xlsX"
693 Content-Disposition: attachment; filename="test.xlsX"
698 Content-Type: application/octet-stream; name="test.exe"
699 Content-Disposition: attachment; filename="test.exe"
704 Content-Type: image/jpeg; name="My_image"
705 Content-Disposition: inline; filename="My_image"
710 Content-Type: video/mp4; name="My_video"
711 Content-Disposition: inline; filename="My_video"
717 process
= config
.handlers
['mime-delete'].process
718 with
dummy_script('report'):
719 process(self
._mlist
, msg
, {})
720 self
.assertIn('multipart/mixed', msg
['content-type'])
721 self
.assertEqual(len(msg
.get_payload()), 3)
722 self
.assertIn('text/plain', msg
.get_payload(0).get_content_type())
723 self
.assertIn('image/jpeg', msg
.get_payload(1).get_content_type())
724 self
.assertEqual(msg
.get_payload(2).get_payload(decode
=True), b
"""\
726 ___________________________________________
727 Mailman's content filtering has removed the
728 following MIME parts from this message.
730 Content-Type: application/octet-stream
733 Content-Type: application/octet-stream
736 Content-Type: video/mp4
739 Replaced multipart/alternative part with first alternative.
742 def test_report_other_multi(self
):
743 # Hit all the pass and filter conditions for reporting
744 self
._mlist
.pass_extensions
= ['txt']
745 self
._mlist
.pass_types
= ['text', 'application', 'multipart']
747 From: anne@example.com
749 Subject: Testing mixed extension
752 Content-Type: multipart/signed; boundary="AAAA"
755 Content-Type: multipart/alternative; boundary="BBBB"
758 Content-Type: text/plain; charset="utf-8"
763 Content-Type: application/pkcs7-signature
769 Content-Type: application/pkcs7-signature
773 Content-Type: application/octet-stream; name="test.xlsX"
774 Content-Disposition: attachment; filename="test.xlsX"
780 process
= config
.handlers
['mime-delete'].process
781 with
dummy_script('report'):
782 process(self
._mlist
, msg
, {})
783 self
.assertIn('multipart/mixed', msg
['content-type'])
784 self
.assertEqual(len(msg
.get_payload()), 2)
785 self
.assertIn('multipart/signed',
786 msg
.get_payload(0).get_content_type())
787 self
.assertIn('text/plain', msg
.get_payload(1).get_content_type())
788 self
.assertEqual(msg
.get_payload(1).get_payload(decode
=True), b
"""\
790 ___________________________________________
791 Mailman's content filtering has removed the
792 following MIME parts from this message.
794 Content-Type: application/octet-stream
797 Replaced multipart/alternative part with first alternative.