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