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