]> git.phdru.name Git - mimedecode.git/blob - mimedecode.py
mimedecode.py
[mimedecode.git] / mimedecode.py
1 #! /usr/local/bin/python -O
2 """Decode MIME message.
3
4 Author: Oleg Broytmann <phd@phd.pp.ru>
5 Copyright: (C) 2001-2002 PhiloSoft Design
6 License: GPL
7 """
8
9 __version__ = "1.1.7"
10
11
12 import sys, os
13 import mimetools
14
15 try:
16    from cStringIO import StringIO
17 except ImportError:
18    from StringIO import StringIO
19
20
21 import socket
22 host_name = socket.gethostname()
23
24 me = os.path.basename(sys.argv[0])
25
26
27 def usage(code=0):
28    sys.stdout.write("""\
29 Usage: %s [-h|--help] [-V|--version] [-cCDP] [-f charset] [-d header] [-p header:param] [-beit mask] [filename]
30 """ % me)
31    sys.exit(code)
32
33
34 def version():
35    sys.stdout.write("""\
36 BroytMann mimedecode.py version %s
37 """ % __version__)
38    sys.exit(0)
39
40
41 def output(s):
42    sys.stdout.write(s)
43
44 def output_headers(msg):
45    if msg.unixfrom:
46       output(msg.unixfrom)
47    output("%s\n" % msg)
48
49
50 def recode(s, charset):
51    return unicode(s, charset, "replace").encode(GlobalOptions.default_charset, "replace")
52
53
54 def recode2(s, charset):
55    if charset and charset <> GlobalOptions.default_charset:
56       charset = charset.lower()
57       s = recode(s, charset)
58    return s
59
60
61 def getparam(msg, header, param):
62    "Get parameter from the header; return the header without the parameter, parameter itself and rfc2231 flag"
63
64    if not msg.has_key(header):
65       return None, None, 0
66
67    header = msg[header]
68    parts = [part.strip() for part in header.split(';')]
69
70    new_parts = [parts[0]] # The header itself
71    del parts[0]
72
73    new_value = None
74    rfc2231_encoded = 0
75
76    import re, rfc822
77    rfc2231_continuation = re.compile("^%s\\*[0-9]+\\*?$" % param)
78    rfc2231_header = []
79
80    for part in parts:
81       name, value = part.split('=', 1)
82       # The code is incomplete. Continuations in rfc2231-encoded paramters
83       # (header*1, header*2, etc) are not yet supported
84       if (name == param) or (name == param + '*'):
85          new_value = rfc822.unquote(value)
86          rfc2231_encoded += (name <> param)
87       elif rfc2231_continuation.match(name):
88          rfc2231_header.append(rfc822.unquote(value))
89          rfc2231_encoded = 1
90       else:
91          new_parts.append(part)
92
93    if rfc2231_header:
94       new_value = ''.join(rfc2231_header)
95
96    if new_value is not None:
97       return "; ".join(new_parts), new_value, rfc2231_encoded
98
99    return None, None, 0
100
101
102 def decode_header(msg, header):
103    "Decode mail header (if exists) and put it back, if it was encoded"
104
105    if msg.has_key(header):
106       value = msg[header]
107       new_value = decode_rfc2047(value)
108       if value <> new_value: # do not bother to touch msg if not changed
109          msg[header] = new_value
110
111
112 def decode_header_param(msg, header, param):
113    "Decode mail header's parameter (if exists) and put it back, if it was encoded"
114
115    if msg.has_key(header):
116       new_value, pstr, rfc2231_encoded = getparam(msg, header, param)
117       if pstr is not None:
118          if rfc2231_encoded:
119             new_str = decode_rfc2231(pstr)
120          else:
121             new_str = decode_rfc2047(pstr)
122          if pstr <> new_str: # do not bother to touch msg if not changed
123             msg[header] = "%s; %s=\"%s\"" % (new_value, param, new_str)
124
125
126 def decode_rfc2047(s):
127    "Decode string according to rfc2047"
128
129    parts = s.split() # by whitespaces
130    new_parts = []
131    got_encoded = 0
132
133    for s in parts:
134       l = s.split('?')
135
136       if l[0] <> '=' or l[4] <> '=': # assert correct format
137          new_parts.append(' ')
138          new_parts.append(s) # if not encoded - just put it into output
139          got_encoded = 0
140          continue
141
142       if not got_encoded:
143          new_parts.append(' ') # no space between encoded parts, one space otherwise
144          got_encoded = 1
145
146       charset = l[1].lower()
147       encoding = l[2].lower()
148       s = l[3]
149
150       if '*' in charset:
151          charset, language = charset.split('*', 1) # language ignored
152
153       infile = StringIO(s)
154       outfile = StringIO()
155
156       if encoding == "b":
157          from base64 import decode
158       elif encoding == "q":
159          from quopri import decode
160       else:
161          raise ValueError, "wrong encoding `%s' (expected 'b' or 'q')" % encoding
162
163       decode(infile, outfile)
164       s = outfile.getvalue()
165
166       if charset == GlobalOptions.default_charset:
167          new_parts.append(s) # do not recode
168          continue
169
170       s = recode(s, charset)
171       new_parts.append(s)
172
173    if new_parts and new_parts[0] == ' ':
174       del new_parts[0]
175    return ''.join(new_parts)
176
177
178 def decode_rfc2231(s):
179    "Decode string according to rfc2231"
180
181    charset, language, s = s.split("'", 2) # language ignored
182
183    i = 0
184    result = []
185
186    while i < len(s):
187       c = s[i]
188       if c == '%': # hex
189          i += 1
190          c = chr(int(s[i:i+2], 16))
191          i += 1
192       result.append(c)
193       i += 1
194
195    s = ''.join(result)
196    s = recode2(s, charset)
197    return s
198
199
200 def decode_headers(msg):
201    "Decode message headers according to global options"
202
203    for header in GlobalOptions.decode_headers:
204       decode_header(msg, header)
205
206    for header, param in GlobalOptions.decode_header_params:
207       decode_header_param(msg, header, param)
208       if header.lower() == "content-type" and msg.has_key(header):
209          # reparse type
210          msg.typeheader = msg[header]
211          msg.parsetype() # required for plist...
212          msg.parseplist() #... and reparse decoded plist
213
214
215 def set_content_type(msg, newtype, charset=None):
216    plist = msg.getplist()
217    if plist:
218       if charset:
219          newplist = []
220          for p in plist:
221             if p.split('=')[0] == "charset":
222                p = "charset=%s" % charset
223             newplist.append(p)
224          plist = newplist
225
226    elif charset:
227       plist = ["charset=%s" % charset]
228
229    else:
230       plist = []
231
232    if plist and plist[0]: plist.insert(0, '')
233    msg["Content-Type"] = "%s%s" % (newtype, ";\n ".join(plist))
234
235
236 caps = None # Globally stored mailcap database; initialized only if needed
237
238 def decode_body(msg, s):
239    "Decode body to plain text using first copiousoutput filter from mailcap"
240
241    import mailcap, tempfile
242
243    global caps
244    if caps is None:
245       caps = mailcap.getcaps()
246
247    content_type = msg.gettype()
248    filename = tempfile.mktemp()
249    command = None
250
251    entries = mailcap.lookup(caps, content_type, "view")
252    for entry in entries:
253       if entry.has_key('copiousoutput'):
254          if entry.has_key('test'):
255             test = mailcap.subst(entry['test'], content_type, filename)
256             if test and os.system(test) != 0:
257                continue
258          command = mailcap.subst(entry["view"], content_type, filename)
259          break
260
261    if not command:
262       return s
263
264    file = open(filename, 'w')
265    file.write(s)
266    file.close()
267
268    pipe = os.popen(command, 'r')
269    s = pipe.read()
270    pipe.close()
271    os.remove(filename)
272
273    set_content_type(msg, "text/plain")
274    msg["X-MIME-Body-Autoconverted"] = "from %s to text/plain by %s id %s" % (content_type, host_name, command.split()[0])
275
276    msg.maintype = "text"
277    msg.subtype = "plain"
278    msg.type = "text/plain"
279
280    return s
281
282
283 def recode_charset(msg, s):
284    "Recode charset of the message to the default charset"
285
286    save_charset = charset = msg.getparam("charset")
287    if charset and charset <> GlobalOptions.default_charset:
288       s = recode2(s, charset)
289       content_type = msg.gettype()
290       set_content_type(msg, content_type, GlobalOptions.default_charset)
291       msg["X-MIME-Charset-Autoconverted"] = "from %s to %s by %s id %s" % (save_charset, GlobalOptions.default_charset, host_name, me)
292    return s
293
294
295 def totext(msg, infile):
296    "Convert infile (StringIO) content to text"
297
298    if msg.getmaintype() == "multipart": # Recursively decode all parts of the multipart message
299       newfile = StringIO("%s\n%s" % (msg, infile.getvalue()))
300       decode_file(newfile)
301       return
302
303    # Decode body and recode charset
304    s = decode_body(msg, infile.getvalue())
305    if GlobalOptions.recode_charset:
306       s = recode_charset(msg, s)
307
308    output_headers(msg)
309    output(s)
310
311
312 def decode_part(msg, infile):
313    "Decode one part of the message"
314
315    encoding = msg.getencoding()
316    outfile = StringIO()
317
318    if encoding in ('', '7bit', '8bit', 'binary'):
319       mimetools.copyliteral(infile, outfile)
320    else: # Decode from transfer ecoding to text or binary form
321       mimetools.decode(infile, outfile, encoding)
322       msg["Content-Transfer-Encoding"] = "8bit"
323       msg["X-MIME-Autoconverted"] = "from %s to 8bit by %s id %s" % (encoding, host_name, me)
324
325    decode_headers(msg)
326
327    # Test all mask lists and find what to do with this content type
328
329    for content_type in msg.gettype(), msg.getmaintype()+"/*", "*/*":
330       if content_type in GlobalOptions.totext_mask:
331          totext(msg, outfile)
332          return
333       elif content_type in GlobalOptions.binary_mask:
334          output_headers(msg)
335          output(outfile.getvalue())
336          return
337       elif content_type in GlobalOptions.ignore_mask:
338          output_headers(msg)
339          output("\nMessage body of type `%s' skipped.\n" % content_type)
340          return
341       elif content_type in GlobalOptions.error_mask:
342          raise ValueError, "content type `%s' prohibited" % content_type
343
344    # Neither content type nor masks were listed - decode by default
345    totext(msg, outfile)
346
347
348 def decode_file(infile, seekable=1):
349    "Decode the entire message"
350
351    m = mimetools.Message(infile)
352    boundary = m.getparam("boundary")
353
354    if not boundary:
355       if not m.getheader("Content-Type"): # Not a message, just text - copy it literally
356          output(infile.read())
357
358       else: # Simple one-part message - decode it
359          decode_part(m, infile)
360
361    else: # MIME message - decode all parts; may be recursive
362       decode_headers(m)
363       output_headers(m)
364
365       import multifile
366       mf = multifile.MultiFile(infile, seekable)
367       mf.push(boundary)
368
369       if not seekable: # Preserve the first part, it is probably not a RFC822-message
370          output(mf.read()) # Usually it is just a few lines of text (MIME warning)
371
372       while 1:
373          m = mimetools.Message(mf)
374          decode_part(m, mf)
375
376          if not mf.next():
377             break
378          output("\n--%s\n" % boundary)
379
380       mf.pop()
381       output("\n--%s--\n" % boundary)
382
383
384 class GlobalOptions:
385    default_charset = sys.getdefaultencoding()
386    recode_charset = 1 # recode charset of message body
387
388    decode_headers = ["Subject", "From"] # A list of headers to decode
389    decode_header_params = [("Content-Type", "name"),
390       ("Content-Disposition", "filename")
391    ] # A list of headers' parameters to decode
392
393    totext_mask = [] # A list of content-types to decode
394    binary_mask = [] # A list to pass through
395    ignore_mask = [] # Ignore (skip, do not decode and do not include into output)
396    error_mask = []  # Raise error if encounter one of these
397
398
399 def init():
400    from getopt import getopt, GetoptError
401
402    try:
403       options, arguments = getopt(sys.argv[1:], 'hVcCDPf:d:p:b:e:i:t:',
404          ['help', 'version'])
405    except GetoptError:
406       usage(1)
407
408    for option, value in options:
409       if option == '-h':
410          usage()
411       elif option == '--help':
412          usage()
413       elif option == '-V':
414          version()
415       elif option == '--version':
416          version()
417       elif option == '-c':
418          GlobalOptions.recode_charset = 1
419       elif option == '-C':
420          GlobalOptions.recode_charset = 0
421       elif option == '-f':
422          GlobalOptions.default_charset = value
423       elif option == '-d':
424          GlobalOptions.decode_headers.append(value)
425       elif option == '-D':
426          GlobalOptions.decode_headers = []
427       elif option == '-p':
428          GlobalOptions.decode_header_params.append(value.split(':', 1))
429       elif option == '-P':
430          GlobalOptions.decode_header_params = []
431       elif option == '-t':
432          GlobalOptions.totext_mask.append(value)
433       elif option == '-b':
434          GlobalOptions.binary_mask.append(value)
435       elif option == '-i':
436          GlobalOptions.ignore_mask.append(value)
437       elif option == '-e':
438          GlobalOptions.error_mask.append(value)
439       else:
440          usage(1)
441
442    return arguments
443
444
445 if __name__ == "__main__":
446    arguments = init()
447
448    seekable = 0
449    if len(arguments) == 0:
450       infile = sys.stdin
451    elif len(arguments) <> 1:
452       usage(1)
453    elif arguments[0] == '-':
454       infile = sys.stdin
455    else:
456       infile = open(arguments[0], 'r')
457       seekable = 1
458
459    decode_file(infile, seekable)