0b334652e6fdc90fd80d49e57e0a879a480327f2
[be.git] / misc / xml / be-mail-to-xml
1 #!/usr/bin/env python
2 # Copyright (C) 2009-2010 W. Trevor King <wking@drexel.edu>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along
15 # with this program; if not, write to the Free Software Foundation, Inc.,
16 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17 """
18 Convert an mbox into xml suitable for input into be.
19   $ be-mbox-to-xml file.mbox | be import-xml -c <ID> -
20 mbox is a flat-file format, consisting of a series of messages.
21 Messages begin with a a From_ line, followed by RFC 822 email,
22 followed by a blank line.
23 """
24
25 import base64
26 import codecs
27 import email.utils
28 from libbe.util.encoding import get_output_encoding
29 from libbe.util.utility import time_to_str
30 import mailbox # the mailbox people really want an on-disk copy
31 import optparse
32 import sys
33 from time import asctime, gmtime, mktime
34 import types
35 from xml.sax.saxutils import escape
36
37 BREAK = u'--' # signature separator
38 DEFAULT_ENCODING = get_output_encoding()
39 sys.stdout = codecs.getwriter(DEFAULT_ENCODING)(sys.stdout)
40
41 KNOWN_IDS = []
42
43 def normalize_email_address(address):
44     """
45     Standardize whitespace, etc.
46     """
47     addr = email.utils.formataddr(email.utils.parseaddr(address))
48     if len(addr) == 0:
49         return None
50     return addr
51
52 def normalize_RFC_2822_date(date):
53     """
54     Some email clients write non-RFC 2822-compliant date tags like:
55       Fri, 18 Sep 2009 08:49:02 -0400 (EDT)
56     with the non-standard (EDT) timezone name.  This funtion attempts
57     to deal with such inconsistencies.
58     """
59     time_tuple = email.utils.parsedate(date)
60     assert time_tuple != None, \
61         'unparsable date: "%s"' % date
62     return time_to_str(mktime(time_tuple))
63
64 def strip_footer(body):
65     body_lines = body.splitlines()
66     for i,line in enumerate(body_lines):
67         if line.startswith(BREAK):
68             break
69         i += 1 # increment past the current valid line.
70     return u'\n'.join(body_lines[:i]).strip()
71
72 def comment_message_to_xml(message, fields=None):
73     if fields == None:
74         fields = {}
75     new_fields = {}
76     new_fields[u'alt-id'] = message[u'message-id']
77     new_fields[u'in-reply-to'] = message[u'in-reply-to']
78     new_fields[u'author'] = normalize_email_address(message[u'from'])
79     new_fields[u'date'] = message[u'date']
80     if new_fields[u'date'] != None:
81         new_fields[u'date'] = normalize_RFC_2822_date(new_fields[u'date'])
82     new_fields[u'content-type'] = message.get_content_type()
83     for k,v in new_fields.items():
84         if v != None and type(v) != types.UnicodeType:
85             fields[k] = unicode(v, encoding=DEFAULT_ENCODING)
86         elif v == None and k in fields:
87             new_fields[k] = fields[k]
88     for k,v in fields.items():
89         if k not in new_fields:
90             new_fields.k = fields[k]
91     fields = new_fields
92
93     if fields[u'in-reply-to'] == None:
94         if message[u'references'] != None:
95             refs = message[u'references'].split()
96             for ref in refs: # search for a known reference id.
97                 if ref in KNOWN_IDS:
98                     fields[u'in-reply-to'] = ref
99                     break
100             if fields[u'in-reply-to'] == None and len(refs) > 0:
101                 fields[u'in-reply-to'] = refs[0] # default to the first
102     else: # check for mutliple in-reply-to references.
103         refs = fields[u'in-reply-to'].split()
104         found_ref = False
105         for ref in refs: # search for a known reference id.
106             if ref in KNOWN_IDS:
107                 fields[u'in-reply-to'] = ref
108                 found_ref = True
109                 break
110         if found_ref == False and len(refs) > 0:
111             fields[u'in-reply-to'] = refs[0] # default to the first
112
113     if fields[u'alt-id'] != None:
114         KNOWN_IDS.append(fields[u'alt-id'])
115
116     if message.is_multipart():
117         ret = []
118         alt_id = fields[u'alt-id']
119         from_str = fields[u'author']
120         date = fields[u'date']
121         for m in message.walk():
122             if m == message:
123                 continue
124             fields[u'author'] = from_str
125             fields[u'date'] = date
126             if len(ret) > 0: # we've added one part already
127                 fields.pop(u'alt-id') # don't pass alt-id to other parts
128                 fields[u'in-reply-to'] = alt_id # others respond to first
129             ret.append(comment_message_to_xml(m, fields))
130             return u'\n'.join(ret)
131
132     charset = message.get_content_charset(DEFAULT_ENCODING).lower()
133     #assert charset == DEFAULT_ENCODING.lower(), \
134     #    u"Unknown charset: %s" % charset
135
136     if message[u'content-transfer-encoding'] == None:
137         encoding = DEFAULT_ENCODING
138     else:
139         encoding = message[u'content-transfer-encoding'].lower()
140     body = message.get_payload(decode=True) # attempt to decode
141     assert body != None, "Unable to decode?"
142     if fields[u'content-type'].startswith(u"text/"):
143         body = strip_footer(unicode(body, encoding=charset))
144     else:
145         body = base64.encode(body)
146     fields[u'body'] = body
147     lines = [u"<comment>"]
148     for tag,body in fields.items():
149         if body != None:
150             ebody = escape(body)
151             lines.append(u"  <%s>%s</%s>" % (tag, ebody, tag))
152     lines.append(u"</comment>")
153     return u'\n'.join(lines)
154
155 def main(argv):
156     parser = optparse.OptionParser(usage='%prog [options] mailbox')
157     formats = ['mbox', 'Maildir', 'MH', 'Babyl', 'MMDF']
158     parser.add_option('-f', '--format', type='choice', dest='format',
159                       help="Select the mailbox format from %s.  See the mailbox module's documention for descriptions of these formats." \
160                           % ', '.join(formats),
161                       default='mbox', choices=formats)
162     options,args = parser.parse_args(argv)
163     mailbox_file = args[1]
164     reader = getattr(mailbox, options.format)
165     mb = reader(mailbox_file, factory=None)
166     print u'<?xml version="1.0" encoding="%s" ?>' % DEFAULT_ENCODING
167     print u"<be-xml>"
168     for message in mb:
169         print comment_message_to_xml(message)
170     print u"</be-xml>"
171
172
173 if __name__ == "__main__":
174     import sys
175     main(sys.argv)