5dbb80b286a64eaf110e042b88c8ac1deb2d1081
[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=None):
91     local_connection = False
92     try:
93         if not connection:
94             local_connection = True
95             connection = connect()
96         post = ''
97         if query:
98             post = '*'
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         r = connection.search_s(
106             CONFIG.get('connection', 'basedn'),
107             ldap.SCOPE_SUBTREE,
108             filterstr.encode('utf-8'))
109     finally:
110         if local_connection and connection:
111             connection.unbind()
112     return r
113
114 def format_columns(address, data):
115     yield address
116     yield data.get('displayName', data['cn'])[-1]
117     optional_column = CONFIG.get('results', 'optional_column')
118     if optional_column in data:
119         yield data[optional_column][-1]
120
121 def format_entry(entry):
122     cn,data = entry
123     if 'mail' in data:
124         for m in data['mail']:
125             # http://www.mutt.org/doc/manual/manual-4.html#ss4.5
126             # Describes the format mutt expects: address\tname
127             yield "\t".join(format_columns(m, data))
128
129 def cache_filename(query):
130     # TODO: is the query filename safe?
131     return os.path.expanduser(CONFIG.get('cache', 'path')) + os.sep + query
132
133 def settings_match(serialized_settings):
134     """Check to make sure the settings are the same for this cache"""
135     return pickle.dumps(CONFIG) == serialized_settings
136
137 def cache_lookup(query):
138     hit = False
139     addresses = []
140     if CONFIG.get('cache', 'enable') == 'yes':
141         cache_file = cache_filename(query)
142         cache_dir = os.path.dirname(cache_file)
143         if not os.path.exists(cache_dir): os.mkdir(cache_dir)
144
145         # TODO: validate longevity setting
146
147         if os.path.exists(cache_file):
148             cache_info = pickle.loads(open(cache_file).read())
149             if settings_match(cache_info['settings']):
150                 hit = True
151                 addresses = cache_info['addresses']
152
153     return hit, addresses
154
155 def cache_persist(query, addresses):
156     cache_info = {
157         'settings':  pickle.dumps(CONFIG),
158         'addresses': addresses
159         }
160     fd = open(cache_filename(query), 'w')
161     pickle.dump(cache_info, fd)
162     fd.close()
163
164 if __name__ == '__main__':
165     import sys
166
167     query = unicode(' '.join(sys.argv[1:]), 'utf-8')
168
169     (cache_hit, addresses) = cache_lookup(query)
170
171     if not cache_hit:
172         entries = search(query)
173         addresses = list(itertools.chain(
174                 *[format_entry(e) for e in sorted(entries)]))
175
176         # Cache results for next lookup
177         cache_persist(query, addresses)
178
179     print('{0} addresses found:'.format(len(addresses)))
180     print('\n'.join(addresses))