diff options
-rw-r--r-- | exchange.cfg | 11 | ||||
-rw-r--r-- | exchange.py | 350 |
2 files changed, 361 insertions, 0 deletions
diff --git a/exchange.cfg b/exchange.cfg new file mode 100644 index 0000000..6a96abe --- /dev/null +++ b/exchange.cfg | |||
@@ -0,0 +1,11 @@ | |||
1 | [exchange] | ||
2 | server = <your_server> | ||
3 | user = <your_username> | ||
4 | |||
5 | [local_server] | ||
6 | address = 0.0.0.0 | ||
7 | port = 8586 | ||
8 | |||
9 | [alarms] | ||
10 | enabled = True | ||
11 | minutes_before = 15 | ||
diff --git a/exchange.py b/exchange.py new file mode 100644 index 0000000..42ba547 --- /dev/null +++ b/exchange.py | |||
@@ -0,0 +1,350 @@ | |||
1 | """ | ||
2 | Exchange API -> iCal Fie Proxy | ||
3 | |||
4 | @author: Mike Crute (mcrute@gmail.com) | ||
5 | @date: November 10, 2008 | ||
6 | @version: $Revision$ | ||
7 | |||
8 | This is a set of classes that starts to define a set of classes for | ||
9 | fetching data using Exchange's WebDAV API. This is still pretty | ||
10 | development code but it does the trick. Watch out, it doesn't consider | ||
11 | many corner cases. | ||
12 | |||
13 | == Config File Format == | ||
14 | [exchange] | ||
15 | server = <your_mail_server> | ||
16 | |||
17 | [authentication] | ||
18 | user = <your_username> | ||
19 | password = <your_password> | ||
20 | |||
21 | [server] | ||
22 | bind_address = 0.0.0.0 | ||
23 | bind_port = 8000 | ||
24 | """ | ||
25 | |||
26 | ###################################################################### | ||
27 | # Standard Library Imports | ||
28 | ###################################################################### | ||
29 | import urllib | ||
30 | import xml.etree.cElementTree as etree | ||
31 | |||
32 | from getpass import getpass | ||
33 | from copy import copy | ||
34 | from httplib import HTTPSConnection | ||
35 | from string import Template | ||
36 | from Cookie import SimpleCookie | ||
37 | from datetime import datetime, timedelta, tzinfo | ||
38 | |||
39 | ###################################################################### | ||
40 | # Third-Party Library Imports | ||
41 | ###################################################################### | ||
42 | #: These can all be found on pypi | ||
43 | import dateutil.parser | ||
44 | from icalendar import Calendar, Event, Alarm | ||
45 | |||
46 | |||
47 | ###################################################################### | ||
48 | # Exceptions | ||
49 | ###################################################################### | ||
50 | class ExchangeException(Exception): | ||
51 | """ | ||
52 | Exception that is thrown by all Exchange handling code. | ||
53 | """ | ||
54 | pass | ||
55 | |||
56 | |||
57 | ###################################################################### | ||
58 | # Exchange Enumerations | ||
59 | ###################################################################### | ||
60 | class CalendarInstanceType(object): | ||
61 | """ | ||
62 | Enum for Calendar Instance Types | ||
63 | @see: http://msdn.microsoft.com/en-us/library/ms870457(EXCHG.65).aspx | ||
64 | |||
65 | This ended up not being used but its probably good to keep around for | ||
66 | future use. | ||
67 | """ | ||
68 | #: Single appointment | ||
69 | SINGLE = 0 | ||
70 | |||
71 | #: Master recurring appointment | ||
72 | MASTER = 1 | ||
73 | |||
74 | #: Single instance of a recurring appointment | ||
75 | INSTANCE = 2 | ||
76 | |||
77 | #: Exception to a recurring appointment | ||
78 | EXCEPTION = 3 | ||
79 | |||
80 | |||
81 | ###################################################################### | ||
82 | # EST Timezone | ||
83 | ###################################################################### | ||
84 | class EST(tzinfo): | ||
85 | |||
86 | def tzname(self, dt): | ||
87 | return "EST" | ||
88 | |||
89 | def utcoffset(self, dt): | ||
90 | return timedelta(0) | ||
91 | |||
92 | dst = utcoffset | ||
93 | |||
94 | ###################################################################### | ||
95 | # Exchange Authenticators | ||
96 | ###################################################################### | ||
97 | class ExchangeAuthenticator(object): | ||
98 | """ | ||
99 | Exchange Authenticator Interface | ||
100 | """ | ||
101 | _auth_cookie = None | ||
102 | authenticated = False | ||
103 | username = None | ||
104 | |||
105 | def __init__(self, web_server): | ||
106 | self.web_server = web_server | ||
107 | |||
108 | def authenticate(self, username, password): | ||
109 | """ | ||
110 | Authenticate the user and cache the authentication so we aren't | ||
111 | hammering the auth server. Should hanlde expiration eventually. | ||
112 | """ | ||
113 | self.username = username | ||
114 | |||
115 | if self._auth_cookie: | ||
116 | return self._auth_cookie | ||
117 | |||
118 | self._auth_cookie = self._do_authentication(username, password) | ||
119 | return self._auth_cookie | ||
120 | |||
121 | def _do_authentication(self, username, password): | ||
122 | raise NotImplemented("Implement in a subclass.") | ||
123 | |||
124 | def patch_headers(self, headers): | ||
125 | raise NotImplemented("Implement in a subclass.") | ||
126 | |||
127 | class CookieAuthenticator(ExchangeAuthenticator): | ||
128 | #: Authentication DLL on the exchange server | ||
129 | AUTH_DLL = "/exchweb/bin/auth/owaauth.dll" | ||
130 | |||
131 | def _do_authentication(self, username, password): | ||
132 | """ | ||
133 | Does a post to the authentication DLL to fetch a cookie for the session | ||
134 | this can then be passed back to the exchange API for servers that don't | ||
135 | support basicc HTTP auth. | ||
136 | """ | ||
137 | params = urllib.urlencode({ "destination": "https://%s/exchange" % (self.web_server), | ||
138 | "flags": "0", | ||
139 | "username": username, | ||
140 | "password": password, | ||
141 | "SubmitCreds": "Log On", | ||
142 | "trusted": "4" | ||
143 | }) | ||
144 | |||
145 | conn = HTTPSConnection(self.web_server) | ||
146 | conn.request("POST", self.AUTH_DLL, params) | ||
147 | response = conn.getresponse() | ||
148 | |||
149 | cookie = SimpleCookie(response.getheader("set-cookie")) | ||
150 | cookie = ("sessionid=%s" % cookie["sessionid"].value, "cadata=%s" % cookie["cadata"].value) | ||
151 | |||
152 | self.authenticated = True | ||
153 | return "; ".join(cookie) | ||
154 | |||
155 | def patch_headers(self, headers): | ||
156 | """ | ||
157 | Patch the headers dictionary with authentication information and | ||
158 | return the patched dictionary. I'm not a big fan of patching | ||
159 | dictionaries in-place so just make a copy first. | ||
160 | """ | ||
161 | out_headers = copy(headers) | ||
162 | out_headers["Cookie"] = self._auth_cookie | ||
163 | return out_headers | ||
164 | |||
165 | ###################################################################### | ||
166 | # Exchange Command Base Class | ||
167 | ###################################################################### | ||
168 | class ExchangeCommand(object): | ||
169 | """ | ||
170 | Base class for Exchange commands. This really shouldn't be constructed | ||
171 | directly but should be subclassed to do useful things. | ||
172 | """ | ||
173 | |||
174 | #: Base URL for Exchange commands. | ||
175 | BASE_URL = Template("/exchange/${username}/${method}") | ||
176 | |||
177 | #: Basic headers that are required for all requests | ||
178 | BASE_HEADERS = { | ||
179 | "Content-Type": 'text/xml; charset="UTF-8"', | ||
180 | "Depth": "0", | ||
181 | "Translate": "f", | ||
182 | } | ||
183 | |||
184 | def __init__(self, server, authenticator=None): | ||
185 | self.server = server | ||
186 | self.authenticator = authenticator | ||
187 | |||
188 | def _get_xml(self, **kwargs): | ||
189 | """ | ||
190 | Try to get an XML response from the server. | ||
191 | @return: ElementTree response | ||
192 | """ | ||
193 | if not self.authenticator.authenticated: | ||
194 | raise ExchangeException("Not authenticated. Call authenticate() first.") | ||
195 | |||
196 | # Lets forcibly override the username with the user we're querying as | ||
197 | kwargs["username"] = self.authenticator.username | ||
198 | |||
199 | xml = self._get_query(**kwargs) | ||
200 | url = self.BASE_URL.substitute({ "username": self.authenticator.username, | ||
201 | "method": self.exchange_method }) | ||
202 | query = Template(xml).substitute(kwargs) | ||
203 | send_headers = self.authenticator.patch_headers(self.BASE_HEADERS) | ||
204 | |||
205 | conn = HTTPSConnection(self.server) | ||
206 | conn.request(self.dav_method.upper(), url, query, headers=send_headers) | ||
207 | resp = conn.getresponse() | ||
208 | |||
209 | # TODO: Lets determine authentication errors here and fix them. | ||
210 | if int(resp.status) > 299 or int(resp.status) < 200: | ||
211 | raise ExchangeException("%s %s" % (resp.status, resp.reason)) | ||
212 | |||
213 | return etree.fromstring(resp.read()) | ||
214 | |||
215 | def _get_query(self, **kwargs): | ||
216 | """ | ||
217 | Build up the XML query for the server. Mostly just does a lot | ||
218 | of template substitutions, also does a little bit of elementtree | ||
219 | magic to to build the XML query. | ||
220 | """ | ||
221 | declaration = etree.ProcessingInstruction("xml", 'version="1.0"') | ||
222 | |||
223 | request = etree.Element("g:searchrequest", { "xmlns:g": "DAV:" }) | ||
224 | query = etree.SubElement(request, "g:sql") | ||
225 | query.text = Template(self.sql).substitute(kwargs) | ||
226 | |||
227 | output = etree.tostring(declaration) | ||
228 | output += etree.tostring(request) | ||
229 | |||
230 | return output | ||
231 | |||
232 | |||
233 | ###################################################################### | ||
234 | # Exchange Commands | ||
235 | ###################################################################### | ||
236 | class FetchCalendar(ExchangeCommand): | ||
237 | exchange_method = "calendar" | ||
238 | dav_method = "search" | ||
239 | |||
240 | sql = """ | ||
241 | SELECT | ||
242 | "urn:schemas:calendar:location" AS location, | ||
243 | "urn:schemas:httpmail:normalizedsubject" AS subject, | ||
244 | "urn:schemas:calendar:dtstart" AS start_date, | ||
245 | "urn:schemas:calendar:dtend" AS end_date, | ||
246 | "urn:schemas:calendar:busystatus" AS busy_status, | ||
247 | "urn:schemas:calendar:instancetype" AS instance_type, | ||
248 | "urn:schemas:calendar:timezone" AS timezone_info, | ||
249 | "urn:schemas:httpmail:textdescription" AS description | ||
250 | FROM | ||
251 | Scope('SHALLOW TRAVERSAL OF "/exchange/${username}/calendar/"') | ||
252 | WHERE | ||
253 | NOT "urn:schemas:calendar:instancetype" = 1 | ||
254 | AND "DAV:contentclass" = 'urn:content-classes:appointment' | ||
255 | ORDER BY | ||
256 | "urn:schemas:calendar:dtstart" ASC | ||
257 | """ | ||
258 | |||
259 | def execute(self, alarms=True, alarm_offset=15, **kwargs): | ||
260 | exchange_xml = self._get_xml(**kwargs) | ||
261 | calendar = Calendar() | ||
262 | |||
263 | for item in exchange_xml.getchildren(): | ||
264 | item = item.find("{DAV:}propstat").find("{DAV:}prop") | ||
265 | event = Event() | ||
266 | |||
267 | # These tests may look funny but the result of item.find | ||
268 | # does NOT evaluate to true even though it is not None | ||
269 | # so, we have to check the interface of the returned item | ||
270 | # to make sure its usable. | ||
271 | |||
272 | subject = item.find("subject") | ||
273 | if hasattr(subject, "text"): | ||
274 | event.add("summary", subject.text) | ||
275 | |||
276 | location = item.find("location") | ||
277 | if hasattr(location, "text"): | ||
278 | event.add("location", location.text) | ||
279 | |||
280 | description = item.find("description") | ||
281 | if hasattr(description, "text"): | ||
282 | event.add("description", description.text) | ||
283 | |||
284 | # Dates should always exist | ||
285 | start_date = dateutil.parser.parse(item.find("start_date").text) | ||
286 | event.add("dtstart", start_date) | ||
287 | |||
288 | end_date = dateutil.parser.parse(item.find("end_date").text) | ||
289 | event.add("dtend", end_date) | ||
290 | |||
291 | if item.get("timezone_info"): | ||
292 | """This comes back from Exchange as already formatted | ||
293 | ical data. We probably need to parse and re-construct | ||
294 | it unless the icalendar api lets us just dump it out. | ||
295 | """ | ||
296 | pass | ||
297 | |||
298 | if alarms and start_date > datetime.now(tz=EST()): | ||
299 | alarm = Alarm() | ||
300 | alarm.add("action", "DISPLAY") | ||
301 | alarm.add("description", "REMINDER") | ||
302 | alarm.add("trigger", timedelta(minutes=alarm_offset)) | ||
303 | event.add_component(alarm) | ||
304 | |||
305 | calendar.add_component(event) | ||
306 | |||
307 | return calendar | ||
308 | |||
309 | |||
310 | ###################################################################### | ||
311 | # Testing Server | ||
312 | ###################################################################### | ||
313 | if __name__ == "__main__": | ||
314 | import sys | ||
315 | from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler | ||
316 | from ConfigParser import ConfigParser | ||
317 | |||
318 | config = ConfigParser() | ||
319 | config.read("exchange.cfg") | ||
320 | |||
321 | username = config.get("exchange", "user") | ||
322 | password = getpass("Exchange Password: ") | ||
323 | |||
324 | class CalendarHandler(BaseHTTPRequestHandler): | ||
325 | def do_GET(self): | ||
326 | print "> GET CALENDARS" | ||
327 | |||
328 | server = config.get("exchange", "server") | ||
329 | fetcher = FetchCalendar(server) | ||
330 | |||
331 | authenticator = CookieAuthenticator(server) | ||
332 | authenticator.authenticate(username, password) | ||
333 | fetcher.authenticator = authenticator | ||
334 | |||
335 | calendar = fetcher.execute() | ||
336 | |||
337 | self.wfile.write(calendar.as_string()) | ||
338 | self.wfile.close() | ||
339 | |||
340 | try: | ||
341 | bind_address = config.get("local_server", "address") | ||
342 | bind_port = int(config.get("local_server", "port")) | ||
343 | |||
344 | print "Exchange iCal Proxy Running on port %d" % bind_port | ||
345 | |||
346 | server = HTTPServer((bind_address, bind_port), CalendarHandler) | ||
347 | server.serve_forever() | ||
348 | except KeyboardInterrupt: | ||
349 | print "\n Fine, be like that." | ||
350 | sys.exit() | ||