0348276b08cf2d22509062d51cab06f8d6657e85
[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.read(os.path.expanduser('~/.mutt-ldap.rc'))
68
69 def connect():
70     protocol = 'ldap'
71     if CONFIG.getboolean('connection', 'ssl'):
72         protocol = 'ldaps'
73     url = '{0}://{1}:{2}'.format(
74         protocol,
75         CONFIG.get('connection', 'server'),
76         CONFIG.get('connection', 'port'))
77     connection = ldap.initialize(url)
78     if CONFIG.getboolean('connection', 'starttls') and protocol == 'ldap':
79         connection.start_tls_s()
80     if CONFIG.getboolean('auth', 'gssapi'):
81         sasl = ldap.sasl.gssapi()
82         connection.sasl_interactive_bind_s('', sasl)
83     else:
84         connection.bind(
85             CONFIG.get('auth', 'user'),
86             CONFIG.get('auth', 'password'),
87             ldap.AUTH_SIMPLE)
88     return connection
89
90 def search(query, connection):
91     post = u''
92     if query:
93         post = u'*'
94     filterstr = u'(|{0})'.format(
95         u' '.join([u'({0}=*{1}{2})'.format(field, query, post)
96                    for field in CONFIG.get('query', 'search_fields').split()]))
97     query_filter = CONFIG.get('query', 'filter')
98     if query_filter:
99         filterstr = u'(&({0}){1})'.format(query_filter, filterstr)
100     msg_id = connection.search(
101         CONFIG.get('connection', 'basedn'),
102         ldap.SCOPE_SUBTREE,
103         filterstr.encode('utf-8'))
104     res_type = None
105     while res_type != ldap.RES_SEARCH_RESULT:
106         try:
107             res_type, res_data = connection.result(
108                 msg_id, all=False, timeout=0)
109         except ldap.ADMINLIMIT_EXCEEDED:
110             #print "Partial results"
111             break
112         if res_data:
113             # use `yield from res_data` in Python >= 3.3, see PEP 380
114             for entry in res_data:
115                 yield entry
116
117 def format_columns(address, data):
118     yield address
119     yield data.get('displayName', data['cn'])[-1]
120     optional_column = CONFIG.get('results', 'optional_column')
121     if optional_column in data:
122         yield data[optional_column][-1]
123
124 def format_entry(entry):
125     cn,data = entry
126     if 'mail' in data:
127         for m in data['mail']:
128             # http://www.mutt.org/doc/manual/manual-4.html#ss4.5
129             # Describes the format mutt expects: address\tname
130             yield "\t".join(format_columns(m, data))
131
132 def cache_filename(query):
133     # TODO: is the query filename safe?
134     return os.path.expanduser(CONFIG.get('cache', 'path')) + os.sep + query
135
136 def settings_match(serialized_settings):
137     """Check to make sure the settings are the same for this cache"""
138     return pickle.dumps(CONFIG) == serialized_settings
139
140 def cache_lookup(query):
141     hit = False
142     addresses = []
143     if CONFIG.get('cache', 'enable') == 'yes':
144         cache_file = cache_filename(query)
145         cache_dir = os.path.dirname(cache_file)
146         if not os.path.exists(cache_dir): os.mkdir(cache_dir)
147
148         # TODO: validate longevity setting
149
150         if os.path.exists(cache_file):
151             cache_info = pickle.loads(open(cache_file).read())
152             if settings_match(cache_info['settings']):
153                 hit = True
154                 addresses = cache_info['addresses']
155
156     return hit, addresses
157
158 def cache_persist(query, addresses):
159     cache_info = {
160         'settings':  pickle.dumps(CONFIG),
161         'addresses': addresses
162         }
163     fd = open(cache_filename(query), 'w')
164     pickle.dump(cache_info, fd)
165     fd.close()
166
167 if __name__ == '__main__':
168     import sys
169
170     if len(sys.argv) < 2:
171         sys.stderr.write('{0}: no search string given\n'.format(sys.argv[0]))
172         sys.exit(1)
173
174     query = unicode(' '.join(sys.argv[1:]), 'utf-8')
175
176     (cache_hit, addresses) = cache_lookup(query)
177
178     if not cache_hit:
179         connection = None
180         try:
181             connection = connect()
182             entries = search(query=query, connection=connection)
183             for entry in entries:
184                 addresses.extend(format_entry(entry))
185             cache_persist(query, addresses)
186         finally:
187             if connection:
188                 connection.unbind()
189
190     print('{0} addresses found:'.format(len(addresses)))
191     print('\n'.join(addresses))