]> git.phdru.name Git - mimedecode.git/blob - mimedecode.py
Cleanup: Fix flake8 E701 multiple statements on one line (colon)
[mimedecode.git] / mimedecode.py
1 #! /usr/bin/env python
2 """Decode MIME message"""
3
4 import os
5 import subprocess
6 import sys
7
8 from mimedecode_version import __version__, \
9     __author__, __copyright__, __license__
10
11 if sys.version_info[0] >= 3:
12     # Replace email.message._formatparam with _formatparam from Python 2.7
13     # to avoid re-encoding non-ascii params.
14     import formatparam_27
15
16 me = os.path.basename(sys.argv[0])
17
18
19 def version(exit=1):
20     sys.stdout.write("""\
21 Broytman mimedecode.py version %s, %s
22 """ % (__version__, __copyright__))
23     if exit:
24         sys.exit(0)
25
26
27 def usage(code=0, errormsg=''):
28     version(0)
29     sys.stdout.write("""\
30 Usage: %s [-h|--help] [-V|--version] [-cCDP] [-H|--host=hostname] [-f charset] [-d header1[,h2,...]|*[,-h1,...]] [-p header1[,h2,h3,...]:param1[,p2,p3,...]] [-r header1[,h2,...]|*[,-h1,...]] [-R header1[,h2,h3,...]:param1[,p2,p3,...]] [--set-header header:value] [--set-param header:param=value] [-Bbeit mask] [--save-headers|body|message mask] [-O dest_dir] [-o output_file] [input_file [output_file]]
31 """ % me)  # noqa: E501
32     if errormsg:
33         sys.stderr.write(errormsg + os.linesep)
34     sys.exit(code)
35
36
37 def output_headers(msg):
38     unix_from = msg.get_unixfrom()
39     if unix_from:
40         output(unix_from)
41         output(os.linesep)
42     for key, value in msg.items():
43         output(key)
44         output(": ")
45         value = value.split(';', 1)
46         output(value[0])
47         if len(value) == 2:
48             output(";")
49             output(_decode_header(value[1], strip=False))
50         output(os.linesep)
51     output(os.linesep)  # End of headers
52
53
54 def recode_if_needed(s, charset):
55     if bytes is str:  # Python2
56         if isinstance(s, bytes) and \
57                 charset and charset.lower() != g.default_encoding:
58             s = s.decode(charset, "replace").\
59                 encode(g.default_encoding, "replace")
60     else:  # Python3
61         if isinstance(s, bytes):
62             s = s.decode(charset, "replace")
63     return s
64
65
66 def _decode_header(s, strip=True):
67     """Return a decoded string according to RFC 2047.
68     NOTE: This is almost the same as email.Utils.decode.
69     """
70     import email.header
71
72     L = email.header.decode_header(s)
73     if not isinstance(L, list):
74         # s wasn't decoded
75         return s
76
77     rtn = []
78     for atom, charset in L:
79         atom = recode_if_needed(atom, charset or g.default_encoding)
80         if strip:
81             atom = atom.strip()
82         rtn.append(atom)
83
84     # Now that we've decoded everything, we just need to join all the parts
85     # together into the final string.
86     return ' '.join(rtn)
87
88
89 def decode_header(msg, header):
90     "Decode mail header (if exists) and put it back, if it was encoded"
91
92     if header in msg:
93         value = msg[header]
94         new_value = _decode_header(value)
95         if new_value != value:  # do not bother to touch msg if not changed
96             set_header(msg, header, new_value)
97
98
99 def decode_header_param(msg, header, param):
100     """Decode mail header's parameter
101
102     Decode mail header's parameter (if exists)
103     and put it back if it was encoded.
104     """
105     if header in msg:
106         value = msg.get_param(param, header=header)
107         if value:
108             if isinstance(value, tuple):
109                 new_value = recode_if_needed(value[2], value[0])
110             else:
111                 new_value = _decode_header(value)
112             if new_value != value:  # do not bother to touch msg if not changed
113                 msg.set_param(param, new_value, header)
114
115
116 def _get_exceptions(list):
117     return [x[1:].lower() for x in list[1:] if x[0] == '-']
118
119
120 def _decode_headers_params(msg, header, decode_all_params, param_list):
121     if decode_all_params:
122         params = msg.get_params(header=header)
123         if params:
124             for param, value in params:
125                 if param not in param_list:
126                     decode_header_param(msg, header, param)
127     else:
128         for param in param_list:
129             decode_header_param(msg, header, param)
130
131
132 def _remove_headers_params(msg, header, remove_all_params, param_list):
133     if remove_all_params:
134         params = msg.get_params(header=header)
135         if params:
136             if param_list:
137                 for param, value in params:
138                     if param not in param_list:
139                         msg.del_param(param, header)
140             else:
141                 value = msg[header]
142                 if value is None:  # No such header
143                     return
144                 if ';' not in value:  # There are no parameters
145                     return
146                 del msg[header]  # Delete all such headers
147                 # Get the value without parameters and set it back
148                 msg[header] = value.split(';')[0].strip()
149     else:
150         for param in param_list:
151             msg.del_param(param, header)
152
153
154 def decode_headers(msg):
155     "Decode message headers according to global options"
156
157     for header_list in g.remove_headers:
158         header_list = header_list.split(',')
159         if header_list[0] == '*':  # Remove all headers except listed
160             header_list = _get_exceptions(header_list)
161             for header in msg.keys():
162                 if header.lower() not in header_list:
163                     del msg[header]
164         else:  # Remove listed headers
165             for header in header_list:
166                 del msg[header]
167
168     for header_list, param_list in g.remove_headers_params:
169         header_list = header_list.split(',')
170         param_list = param_list.split(',')
171         # Remove all params except listed.
172         remove_all_params = param_list[0] == '*'
173         if remove_all_params:
174             param_list = _get_exceptions(param_list)
175         if header_list[0] == '*':  # Remove for all headers except listed
176             header_list = _get_exceptions(header_list)
177             for header in msg.keys():
178                 if header.lower() not in header_list:
179                     _remove_headers_params(
180                         msg, header, remove_all_params, param_list)
181         else:  # Decode for listed headers
182             for header in header_list:
183                 _remove_headers_params(
184                     msg, header, remove_all_params, param_list)
185
186     for header_list in g.decode_headers:
187         header_list = header_list.split(',')
188         if header_list[0] == '*':  # Decode all headers except listed
189             header_list = _get_exceptions(header_list)
190             for header in msg.keys():
191                 if header.lower() not in header_list:
192                     decode_header(msg, header)
193         else:  # Decode listed headers
194             for header in header_list:
195                 decode_header(msg, header)
196
197     for header_list, param_list in g.decode_header_params:
198         header_list = header_list.split(',')
199         param_list = param_list.split(',')
200         # Decode all params except listed.
201         decode_all_params = param_list[0] == '*'
202         if decode_all_params:
203             param_list = _get_exceptions(param_list)
204         if header_list[0] == '*':  # Decode for all headers except listed
205             header_list = _get_exceptions(header_list)
206             for header in msg.keys():
207                 if header.lower() not in header_list:
208                     _decode_headers_params(
209                         msg, header, decode_all_params, param_list)
210         else:  # Decode for listed headers
211             for header in header_list:
212                 _decode_headers_params(
213                     msg, header, decode_all_params, param_list)
214
215
216 def set_header(msg, header, value):
217     "Replace header"
218
219     if header in msg:
220         msg.replace_header(header, value)
221     else:
222         msg[header] = value
223
224
225 def set_content_type(msg, newtype, charset=None):
226     msg.set_type(newtype)
227
228     if charset:
229         msg.set_param("charset", charset, "Content-Type")
230
231
232 caps = None  # Globally stored mailcap database; initialized only if needed
233
234
235 def decode_body(msg, s):
236     "Decode body to plain text using first copiousoutput filter from mailcap"
237
238     import mailcap
239     import tempfile
240
241     global caps
242     if caps is None:
243         caps = mailcap.getcaps()
244
245     content_type = msg.get_content_type()
246     if content_type.startswith('text/'):
247         charset = msg.get_content_charset()
248     else:
249         charset = None
250     filename = tempfile.mktemp()
251     command = None
252
253     entries = mailcap.lookup(caps, content_type, "view")
254     for entry in entries:
255         if 'copiousoutput' in entry:
256             if 'test' in entry:
257                 test = mailcap.subst(entry['test'], content_type, filename)
258                 if test and os.system(test) != 0:
259                     continue
260             command = mailcap.subst(entry["view"], content_type, filename)
261             break
262
263     if not command:
264         return s
265
266     outfile = open(filename, 'wb')
267     if charset and bytes is not str and isinstance(s, bytes):  # Python3
268         s = s.decode(charset, "replace")
269     if not isinstance(s, bytes):
270         s = s.encode(g.default_encoding, "replace")
271     outfile.write(s)
272     outfile.close()
273
274     pipe = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
275     new_s = pipe.stdout.read()
276     pipe.stdout.close()
277     if pipe.wait() == 0:  # result=0, Ok
278         s = new_s
279         if bytes is not str and isinstance(s, bytes):  # Python3
280             s = s.decode(g.default_encoding, "replace")
281         if charset and not isinstance(s, bytes):
282             s = s.encode(charset, "replace")
283         set_content_type(msg, "text/plain")
284         msg["X-MIME-Autoconverted"] = \
285             "from %s to text/plain by %s id %s" \
286             % (content_type, g.host_name, command.split()[0])
287     else:
288         msg["X-MIME-Autoconverted"] = \
289             "failed conversion from %s to text/plain by %s id %s" \
290             % (content_type, g.host_name, command.split()[0])
291     os.remove(filename)
292
293     return s
294
295
296 def recode_charset(msg, s):
297     "Recode charset of the message to the default charset"
298
299     save_charset = charset = msg.get_content_charset()
300     if charset and charset.lower() != g.default_encoding:
301         s = recode_if_needed(s, charset)
302         content_type = msg.get_content_type()
303         set_content_type(msg, content_type, g.default_encoding)
304         msg["X-MIME-Autoconverted"] = \
305             "from %s to %s by %s id %s" \
306             % (save_charset, g.default_encoding, g.host_name, me)
307     return s
308
309
310 def totext(msg, instring):
311     "Convert instring content to text"
312
313     # Decode body and recode charset
314     s = decode_body(msg, instring)
315     if g.recode_charset:
316         s = recode_charset(msg, s)
317
318     output_headers(msg)
319     output(s)
320     return s
321
322
323 mimetypes = None
324
325
326 def _guess_extension(ctype):
327     global mimetypes
328     if mimetypes is None:
329         import mimetypes
330         mimetypes.init()
331         user_mime_type = os.path.expanduser('~/.mime.types')
332         if os.path.exists(user_mime_type):
333             mimetypes._db.read(user_mime_type)
334     return mimetypes.guess_extension(ctype)
335
336
337 def _save_message(msg, outstring, save_headers=False, save_body=False):
338     for header, param in (
339         ("Content-Disposition", "filename"),
340         ("Content-Type", "name"),
341     ):
342         fname = msg.get_param(param, header=header)
343         if fname:
344             if isinstance(fname, tuple):
345                 fname = fname[2]  # Do not recode if it isn't recoded yet
346             try:
347                     for forbidden in chr(0), '/', '\\':
348                         if forbidden in fname:
349                             raise ValueError
350             except ValueError:
351                 continue
352             fname = '-' + fname
353             break
354     else:
355         fname = ''
356     g.save_counter += 1
357     fname = str(g.save_counter) + fname
358     if '.' not in fname:
359         ext = _guess_extension(msg.get_content_type())
360         if ext:
361             fname += ext
362
363     global output
364     save_output = output
365     outfile = open_output_file(fname)
366
367     def _output_bytes(s):
368         if not isinstance(s, bytes):
369             s = s.encode(g.default_encoding, "replace")
370         outfile.write(s)
371
372     output = _output_bytes
373     if save_headers:
374         output_headers(msg)
375     if save_body:
376         output(outstring)
377     outfile.close()
378     output = save_output
379
380
381 def decode_part(msg):
382     "Decode one part of the message"
383
384     decode_headers(msg)
385
386     # Test all mask lists and find what to do with this content type
387     masks = []
388     ctype = msg.get_content_type()
389     if ctype:
390         masks.append(ctype)
391         mtype = ctype.split('/')[0]
392         masks.append(mtype + '/*')
393     masks.append('*/*')
394
395     left_binary = False
396     for content_type in masks:
397         if content_type in g.totext_mask or \
398            content_type in g.decoded_binary_mask:
399             break
400         elif content_type in g.binary_mask:
401             left_binary = True
402             break
403         elif content_type in g.fully_ignore_mask:
404             return
405
406     encoding = msg["Content-Transfer-Encoding"]
407     if left_binary or encoding in (None, '', '7bit', '8bit', 'binary'):
408         outstring = msg.get_payload()
409     else:  # Decode from transfer ecoding to text or binary form
410         outstring = msg.get_payload(decode=1)
411         set_header(msg, "Content-Transfer-Encoding", "8bit")
412         msg["X-MIME-Autoconverted"] = \
413             "from %s to 8bit by %s id %s" % (encoding, g.host_name, me)
414
415     for content_type in masks:
416         if content_type in g.totext_mask:
417             outstring = totext(msg, outstring)
418             break
419         elif content_type in g.binary_mask or \
420                 content_type in g.decoded_binary_mask:
421             output_headers(msg)
422             output(outstring)
423             break
424         elif content_type in g.ignore_mask:
425             output_headers(msg)
426             output("%sMessage body of type %s skipped.%s"
427                    % (os.linesep, ctype, os.linesep))
428             break
429         elif content_type in g.error_mask:
430             break
431     else:
432         # Neither content type nor masks were listed - decode by default
433         outstring = totext(msg, outstring)
434
435     for content_type in masks:
436         if content_type in g.save_headers_mask:
437             _save_message(msg, outstring, save_headers=True, save_body=False)
438         if content_type in g.save_body_mask:
439             _save_message(msg, outstring, save_headers=False, save_body=True)
440         if content_type in g.save_message_mask:
441             _save_message(msg, outstring, save_headers=True, save_body=True)
442
443     for content_type in masks:
444         if content_type in g.error_mask:
445             raise ValueError("content type %s prohibited" % ctype)
446
447
448 def decode_multipart(msg):
449     "Decode multipart"
450
451     decode_headers(msg)
452     boundary = msg.get_boundary()
453
454     masks = []
455     ctype = msg.get_content_type()
456     if ctype:
457         masks.append(ctype)
458         mtype = ctype.split('/')[0]
459         masks.append(mtype + '/*')
460     masks.append('*/*')
461
462     for content_type in masks:
463         if content_type in g.fully_ignore_mask:
464             return
465         elif content_type in g.ignore_mask:
466             output_headers(msg)
467             output("%sMessage body of type %s skipped.%s"
468                    % (os.linesep, ctype, os.linesep))
469             if boundary:
470                 output("%s--%s--%s" % (os.linesep, boundary, os.linesep))
471             return
472
473     for content_type in masks:
474         if content_type in g.save_body_mask or \
475                 content_type in g.save_message_mask:
476             _out_l = []
477             first_subpart = True
478             for subpart in msg.get_payload():
479                 if first_subpart:
480                     first_subpart = False
481                 else:
482                     _out_l.append(os.linesep)
483                 _out_l.append("--%s%s" % (boundary, os.linesep))
484                 _out_l.append(subpart.as_string())
485             _out_l.append("%s--%s--%s" % (os.linesep, boundary, os.linesep))
486             outstring = ''.join(_out_l)
487             break
488     else:
489         outstring = None
490
491     for content_type in masks:
492         if content_type in g.save_headers_mask:
493             _save_message(msg, outstring, save_headers=True, save_body=False)
494         if content_type in g.save_body_mask:
495             _save_message(msg, outstring, save_headers=False, save_body=True)
496         if content_type in g.save_message_mask:
497             _save_message(msg, outstring, save_headers=True, save_body=True)
498
499     for content_type in masks:
500         if content_type in g.error_mask:
501             raise ValueError("content type %s prohibited" % ctype)
502
503     output_headers(msg)
504
505     # Preserve the first part, it is probably not a RFC822-message.
506     if msg.preamble:
507         # Usually it is just a few lines of text (MIME warning).
508         output(msg.preamble)
509     if msg.preamble is not None:
510         output(os.linesep)
511
512     first_subpart = True
513     for subpart in msg.get_payload():
514         if boundary:
515             if first_subpart:
516                 first_subpart = False
517             else:
518                 output(os.linesep)
519             output("--%s%s" % (boundary, os.linesep))
520
521         # Recursively decode all parts of the subpart
522         decode_message(subpart)
523
524     if boundary:
525         output("%s--%s--%s" % (os.linesep, boundary, os.linesep))
526
527     if msg.epilogue:
528         output(msg.epilogue)
529
530
531 def decode_message(msg):
532     "Decode message"
533
534     if msg.is_multipart():
535         decode_multipart(msg)
536     elif len(msg):  # Simple one-part message (there are headers) - decode it
537         decode_part(msg)
538     else:  # Not a message, just text - copy it literally
539         output(msg.as_string())
540
541
542 def open_output_file(filename):
543     fullpath = os.path.abspath(os.path.join(g.destination_dir, filename))
544     full_dir = os.path.dirname(fullpath)
545     create = not os.path.isdir(full_dir)
546     if create:
547         os.makedirs(full_dir)
548     try:
549         return open(fullpath, 'wb')
550     except:
551         if create:
552             os.removedirs(full_dir)
553
554
555 class GlobalOptions:
556     from m_lib.defenc import default_encoding
557     recode_charset = 1  # recode charset of message body
558
559     host_name = None
560
561     # A list of headers to decode
562     decode_headers = ["From", "To", "Cc", "Reply-To", "Mail-Followup-To",
563                       "Subject"]
564
565     # A list of headers parameters to decode
566     decode_header_params = [
567         ("Content-Type", "name"),
568         ("Content-Disposition", "filename"),
569     ]
570
571     # A list of headers to remove
572     remove_headers = []
573     # A list of headers parameters to remove
574     remove_headers_params = []
575
576     # A list of header/value pairs to set
577     set_header_value = []
578     # A list of header/parameter/value triples to set
579     set_header_param = []
580
581     totext_mask = []  # A list of content-types to decode
582     binary_mask = []  # A list of content-types to pass through
583     # A list of content-types to pass through (content-transfer-decoded).
584     decoded_binary_mask = []
585     # Ignore (do not decode and do not include into output)
586     # but output a warning instead of the body.
587     ignore_mask = []
588     # Completely ignore - no headers, no body, no warning.
589     fully_ignore_mask = []
590     error_mask = []  # Raise error if encounter one of these
591
592     save_counter = 0
593     save_headers_mask = []
594     save_body_mask = []
595     save_message_mask = []
596
597     input_filename = None
598     output_filename = None
599     destination_dir = os.curdir
600
601
602 g = GlobalOptions
603
604
605 def get_opts():
606     from getopt import getopt, GetoptError
607
608     try:
609         options, arguments = getopt(
610             sys.argv[1:],
611             'hVcCDPH:f:d:p:r:R:b:B:e:I:i:t:O:o:',
612             ['help', 'version', 'host=',
613              'save-headers=', 'save-body=', 'save-message=',
614              'set-header=', 'set-param='])
615     except GetoptError:
616         usage(1)
617
618     for option, value in options:
619         if option in ('-h', '--help'):
620             usage()
621         elif option in ('-V', '--version'):
622             version()
623         elif option == '-c':
624             g.recode_charset = 1
625         elif option == '-C':
626             g.recode_charset = 0
627         elif option in ('-H', '--host'):
628             g.host_name = value
629         elif option == '-f':
630             g.default_encoding = value
631         elif option == '-d':
632             if value.startswith('*'):
633                 g.decode_headers = []
634             g.decode_headers.append(value)
635         elif option == '-D':
636             g.decode_headers = []
637         elif option == '-p':
638             g.decode_header_params.append(value.split(':', 1))
639         elif option == '-P':
640             g.decode_header_params = []
641         elif option == '-r':
642             g.remove_headers.append(value)
643         elif option == '-R':
644             g.remove_headers_params.append(value.split(':', 1))
645         elif option == '--set-header':
646             g.set_header_value.append(value.split(':', 1))
647         elif option == '--set-param':
648             header, value = value.split(':', 1)
649             if '=' in value:
650                 param, value = value.split('=', 1)
651             else:
652                 param, value = value.split(':', 1)
653             g.set_header_param.append((header, param, value))
654         elif option == '-t':
655             g.totext_mask.append(value)
656         elif option == '-B':
657             g.binary_mask.append(value)
658         elif option == '-b':
659             g.decoded_binary_mask.append(value)
660         elif option == '-I':
661             g.fully_ignore_mask.append(value)
662         elif option == '-i':
663             g.ignore_mask.append(value)
664         elif option == '-e':
665             g.error_mask.append(value)
666         elif option == '--save-headers':
667             g.save_headers_mask.append(value)
668         elif option == '--save-body':
669             g.save_body_mask.append(value)
670         elif option == '--save-message':
671             g.save_message_mask.append(value)
672         elif option == '-O':
673             g.destination_dir = value
674         elif option == '-o':
675             g.output_filename = value
676         else:
677             usage(1)
678
679     return arguments
680
681
682 if __name__ == "__main__":
683     arguments = get_opts()
684
685     la = len(arguments)
686     if la == 0:
687         g.input_filename = '-'
688         infile = sys.stdin
689         if g.output_filename:
690             outfile = open_output_file(g.output_filename)
691         else:
692             g.output_filename = '-'
693             outfile = sys.stdout
694     elif la in (1, 2):
695         if (arguments[0] == '-'):
696             g.input_filename = '-'
697             infile = sys.stdin
698         else:
699             g.input_filename = arguments[0]
700             infile = open(arguments[0], 'r')
701         if la == 1:
702             if g.output_filename:
703                 outfile = open_output_file(g.output_filename)
704             else:
705                 g.output_filename = '-'
706                 outfile = sys.stdout
707         elif la == 2:
708             if g.output_filename:
709                 usage(1, 'Too many output filenames')
710             if (arguments[1] == '-'):
711                 g.output_filename = '-'
712                 outfile = sys.stdout
713             else:
714                 g.output_filename = arguments[1]
715                 outfile = open_output_file(g.output_filename)
716     else:
717         usage(1, 'Too many arguments')
718
719     if (infile is sys.stdin) and sys.stdin.isatty():
720         if (outfile is sys.stdout) and sys.stdout.isatty():
721             usage()
722         usage(1, 'Filtering from console is forbidden')
723
724     if not g.host_name:
725         import socket
726         g.host_name = socket.gethostname()
727
728     g.outfile = outfile
729     if hasattr(outfile, 'buffer'):
730         def output_bytes(s):
731             if not isinstance(s, bytes):
732                 s = s.encode(g.default_encoding, "replace")
733             outfile.buffer.write(s)
734         output = output_bytes
735     else:
736         output = outfile.write
737
738     import email
739     msg = email.message_from_file(infile)
740
741     for header, value in g.set_header_value:
742         set_header(msg, header, value)
743
744     for header, param, value in g.set_header_param:
745         if header in msg:
746             msg.set_param(param, value, header)
747
748     try:
749         decode_message(msg)
750     finally:
751         infile.close()
752         outfile.close()