Decode search results to Unicode and print as Unicode
[mutt-ldap.git] / mutt-ldap.py
1 #!/usr/bin/env python2
2 #
3 # Copyright (C) 2008-2013  W. Trevor King
4 # Copyright (C) 2012-2013  Wade Berrier
5 # Copyright (C) 2012       Niels de Vos
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 """LDAP address searches for Mutt.
21
22 Add :file:`mutt-ldap.py` to your ``PATH`` and add the following line
23 to your :file:`.muttrc`::
24
25   set query_command = "mutt-ldap.py '%s'"
26
27 Search for addresses with `^t`, optionally after typing part of the
28 name.  Configure your connection by creating :file:`~/.mutt-ldap.rc`
29 contaning something like::
30
31   [connection]
32   server = myserver.example.net
33   basedn = ou=people,dc=example,dc=net
34
35 See the `CONFIG` options for other available settings.
36 """
37
38 import email.utils
39 import itertools
40 import os.path
41 import ConfigParser
42 import pickle
43
44 import ldap
45 import ldap.sasl
46
47
48 CONFIG = ConfigParser.SafeConfigParser()
49 CONFIG.add_section('connection')
50 CONFIG.set('connection', 'server', 'domaincontroller.yourdomain.com')
51 CONFIG.set('connection', 'port', '389')  # set to 636 for default over SSL
52 CONFIG.set('connection', 'ssl', 'no')
53 CONFIG.set('connection', 'starttls', 'no')
54 CONFIG.set('connection', 'basedn', 'ou=x co.,dc=example,dc=net')
55 CONFIG.add_section('auth')
56 CONFIG.set('auth', 'user', '')
57 CONFIG.set('auth', 'password', '')
58 CONFIG.set('auth', 'gssapi', 'no')
59 CONFIG.add_section('query')
60 CONFIG.set('query', 'filter', '') # only match entries according to this filter
61 CONFIG.set('query', 'search_fields', 'cn displayName uid mail') # fields to wildcard search
62 CONFIG.add_section('results')
63 CONFIG.set('results', 'optional_column', '') # mutt can display one optional column
64 CONFIG.add_section('cache')
65 CONFIG.set('cache', 'enable', 'yes') # enable caching by default
66 CONFIG.set('cache', 'path', '~/.mutt-ldap.cache') # cache results here
67 #CONFIG.set('cache', 'longevity_days', '14') # TODO: cache results for 14 days by default
68 CONFIG.add_section('system')
69 # HACK: Python 2.x support, see http://bugs.python.org/issue2128
70 CONFIG.set('system', 'argv-encoding', 'utf-8')
71
72 CONFIG.read(os.path.expanduser('~/.mutt-ldap.rc'))
73
74
75 def connect():
76     protocol = 'ldap'
77     if CONFIG.getboolean('connection', 'ssl'):
78         protocol = 'ldaps'
79     url = '{0}://{1}:{2}'.format(
80         protocol,
81         CONFIG.get('connection', 'server'),
82         CONFIG.get('connection', 'port'))
83     connection = ldap.initialize(url)
84     if CONFIG.getboolean('connection', 'starttls') and protocol == 'ldap':
85         connection.start_tls_s()
86     if CONFIG.getboolean('auth', 'gssapi'):
87         sasl = ldap.sasl.gssapi()
88         connection.sasl_interactive_bind_s('', sasl)
89     else:
90         connection.bind(
91             CONFIG.get('auth', 'user'),
92             CONFIG.get('auth', 'password'),
93             ldap.AUTH_SIMPLE)
94     return connection
95
96 def search(query, connection):
97     post = u''
98     if query:
99         post = u'*'
100     filterstr = u'(|{0})'.format(
101         u' '.join([u'({0}=*{1}{2})'.format(field, query, post)
102                    for field in CONFIG.get('query', 'search_fields').split()]))
103     query_filter = CONFIG.get('query', 'filter')
104     if query_filter:
105         filterstr = u'(&({0}){1})'.format(query_filter, filterstr)
106     msg_id = connection.search(
107         CONFIG.get('connection', 'basedn'),
108         ldap.SCOPE_SUBTREE,
109         filterstr.encode('utf-8'))
110     res_type = None
111     while res_type != ldap.RES_SEARCH_RESULT:
112         try:
113             res_type, res_data = connection.result(
114                 msg_id, all=False, timeout=0)
115         except ldap.ADMINLIMIT_EXCEEDED:
116             #print "Partial results"
117             break
118         if res_data:
119             # use `yield from res_data` in Python >= 3.3, see PEP 380
120             for entry in res_data:
121                 yield entry
122
123 def format_columns(address, data):
124     yield unicode(address, 'utf-8')
125     yield unicode(data.get('displayName', data['cn'])[-1], 'utf-8')
126     optional_column = CONFIG.get('results', 'optional_column')
127     if optional_column in data:
128         yield unicode(data[optional_column][-1], 'utf-8')
129
130 def format_entry(entry):
131     cn,data = entry
132     if 'mail' in data:
133         for m in data['mail']:
134             # http://www.mutt.org/doc/manual/manual-4.html#ss4.5
135             # Describes the format mutt expects: address\tname
136             yield u'\t'.join(format_columns(m, data))
137
138 def cache_filename(query):
139     # TODO: is the query filename safe?
140     return os.path.expanduser(CONFIG.get('cache', 'path')) + os.sep + query
141
142 def settings_match(serialized_settings):
143     """Check to make sure the settings are the same for this cache"""
144     return pickle.dumps(CONFIG) == serialized_settings
145
146 def cache_lookup(query):
147     hit = False
148     addresses = []
149     if CONFIG.get('cache', 'enable') == 'yes':
150         cache_file = cache_filename(query)
151         cache_dir = os.path.dirname(cache_file)
152         if not os.path.exists(cache_dir): os.mkdir(cache_dir)
153
154         # TODO: validate longevity setting
155
156         if os.path.exists(cache_file):
157             cache_info = pickle.loads(open(cache_file).read())
158             if settings_match(cache_info['settings']):
159                 hit = True
160                 addresses = cache_info['addresses']
161
162     return hit, addresses
163
164 def cache_persist(query, addresses):
165     cache_info = {
166         'settings':  pickle.dumps(CONFIG),
167         'addresses': addresses
168         }
169     fd = open(cache_filename(query), 'w')
170     pickle.dump(cache_info, fd)
171     fd.close()
172
173 if __name__ == '__main__':
174     import sys
175
176     # HACK: convert sys.argv to Unicode (not needed in Python 3)
177     argv_encoding = CONFIG.get('system', 'argv-encoding')
178     sys.argv = [unicode(arg, argv_encoding) for arg in sys.argv]
179
180     if len(sys.argv) < 2:
181         sys.stderr.write(u'{0}: no search string given\n'.format(sys.argv[0]))
182         sys.exit(1)
183
184     query = u' '.join(sys.argv[1:])
185
186     (cache_hit, addresses) = cache_lookup(query)
187
188     if not cache_hit:
189         connection = None
190         try:
191             connection = connect()
192             entries = search(query=query, connection=connection)
193             for entry in entries:
194                 addresses.extend(format_entry(entry))
195             cache_persist(query, addresses)
196         finally:
197             if connection:
198                 connection.unbind()
199
200     print(u'{0} addresses found:'.format(len(addresses)))
201     print(u'\n'.join(addresses))