Merge branch 'master' of zorba@192.168.100.11:questhelper
[QuestHelper.git] / Development / libgmail.py
blobcbf7e649df4841804d16d94bbf1824e858239c82
1 #!/usr/bin/env python
3 # libgmail -- Gmail access via Python
5 ## To get the version number of the available libgmail version.
6 ## Reminder: add date before next release. This attribute is also
7 ## used in the setup script.
8 Version = '0.1.11' # (August 2008)
10 # Original author: follower@rancidbacon.com
11 # Maintainers: Waseem (wdaher@mit.edu) and Stas Z (stas@linux.isbeter.nl)
13 # License: GPL 2.0
15 # NOTE:
16 # You should ensure you are permitted to use this script before using it
17 # to access Google's Gmail servers.
20 # Gmail Implementation Notes
21 # ==========================
23 # * Folders contain message threads, not individual messages. At present I
24 # do not know any way to list all messages without processing thread list.
27 LG_DEBUG=0
28 from lgconstants import *
30 import os,pprint
31 import re
32 import urllib
33 import urllib2
34 import mimetypes
35 import types
36 import mechanize as ClientCookie
37 from cPickle import load, dump
39 from email.MIMEBase import MIMEBase
40 from email.MIMEText import MIMEText
41 from email.MIMEMultipart import MIMEMultipart
43 GMAIL_URL_LOGIN = "https://www.google.com/accounts/ServiceLoginBoxAuth"
44 GMAIL_URL_GMAIL = "https://mail.google.com/mail/?ui=1&"
46 # Set to any value to use proxy.
47 PROXY_URL = None # e.g. libgmail.PROXY_URL = 'myproxy.org:3128'
49 # TODO: Get these on the fly?
50 STANDARD_FOLDERS = [U_INBOX_SEARCH, U_STARRED_SEARCH,
51 U_ALL_SEARCH, U_DRAFTS_SEARCH,
52 U_SENT_SEARCH, U_SPAM_SEARCH]
54 # Constants with names not from the Gmail Javascript:
55 # TODO: Move to `lgconstants.py`?
56 U_SAVEDRAFT_VIEW = "sd"
58 D_DRAFTINFO = "di"
59 # NOTE: All other DI_* field offsets seem to match the MI_* field offsets
60 DI_BODY = 19
62 versionWarned = False # If the Javascript version is different have we
63 # warned about it?
66 RE_SPLIT_PAGE_CONTENT = re.compile("D\((.*?)\);", re.DOTALL)
68 class GmailError(Exception):
69 '''
70 Exception thrown upon gmail-specific failures, in particular a
71 failure to log in and a failure to parse responses.
73 '''
74 pass
76 class GmailSendError(Exception):
77 '''
78 Exception to throw if we are unable to send a message
79 '''
80 pass
82 def _parsePage(pageContent):
83 """
84 Parse the supplied HTML page and extract useful information from
85 the embedded Javascript.
87 """
88 lines = pageContent.splitlines()
89 data = '\n'.join([x for x in lines if x and x[0] in ['D', ')', ',', ']']])
90 #data = data.replace(',,',',').replace(',,',',')
91 data = re.sub(r'("(?:[^\\"]|\\.)*")', r'u\1', data)
92 data = re.sub(',{2,}', ',', data)
94 result = []
95 try:
96 exec data in {'__builtins__': None}, {'D': lambda x: result.append(x)}
97 except SyntaxError,info:
98 print info
99 raise GmailError, 'Failed to parse data returned from gmail.'
101 items = result
102 itemsDict = {}
103 namesFoundTwice = []
104 for item in items:
105 name = item[0]
106 try:
107 parsedValue = item[1:]
108 except Exception:
109 parsedValue = ['']
110 if itemsDict.has_key(name):
111 # This handles the case where a name key is used more than
112 # once (e.g. mail items, mail body etc) and automatically
113 # places the values into list.
114 # TODO: Check this actually works properly, it's early... :-)
116 if len(parsedValue) and type(parsedValue[0]) is types.ListType:
117 for item in parsedValue:
118 itemsDict[name].append(item)
119 else:
120 itemsDict[name].append(parsedValue)
121 else:
122 if len(parsedValue) and type(parsedValue[0]) is types.ListType:
123 itemsDict[name] = []
124 for item in parsedValue:
125 itemsDict[name].append(item)
126 else:
127 itemsDict[name] = [parsedValue]
129 return itemsDict
131 def _splitBunches(infoItems):# Is this still needed ?? Stas
133 Utility to help make it easy to iterate over each item separately,
134 even if they were bunched on the page.
136 result= []
137 # TODO: Decide if this is the best approach.
138 for group in infoItems:
139 if type(group) == tuple:
140 result.extend(group)
141 else:
142 result.append(group)
143 return result
145 class SmartRedirectHandler(ClientCookie.HTTPRedirectHandler):
146 def __init__(self, cookiejar):
147 self.cookiejar = cookiejar
149 def http_error_302(self, req, fp, code, msg, headers):
150 # The location redirect doesn't seem to change
151 # the hostname header appropriately, so we do
152 # by hand. (Is this a bug in urllib2?)
153 new_host = re.match(r'http[s]*://(.*?\.google\.com)',
154 headers.getheader('Location'))
155 if new_host:
156 req.add_header("Host", new_host.groups()[0])
157 result = ClientCookie.HTTPRedirectHandler.http_error_302(
158 self, req, fp, code, msg, headers)
159 return result
162 def _buildURL(**kwargs):
165 return "%s%s" % (URL_GMAIL, urllib.urlencode(kwargs))
169 def _paramsToMime(params, filenames, files):
172 mimeMsg = MIMEMultipart("form-data")
174 for name, value in params.iteritems():
175 mimeItem = MIMEText(value)
176 mimeItem.add_header("Content-Disposition", "form-data", name=name)
178 # TODO: Handle this better...?
179 for hdr in ['Content-Type','MIME-Version','Content-Transfer-Encoding']:
180 del mimeItem[hdr]
182 mimeMsg.attach(mimeItem)
184 if filenames or files:
185 filenames = filenames or []
186 files = files or []
187 for idx, item in enumerate(filenames + files):
188 # TODO: This is messy, tidy it...
189 if isinstance(item, str):
190 # We assume it's a file path...
191 filename = item
192 contentType = mimetypes.guess_type(filename)[0]
193 payload = open(filename, "rb").read()
194 else:
195 # We assume it's an `email.Message.Message` instance...
196 # TODO: Make more use of the pre-encoded information?
197 filename = item.get_filename()
198 contentType = item.get_content_type()
199 payload = item.get_payload(decode=True)
201 if not contentType:
202 contentType = "application/octet-stream"
204 mimeItem = MIMEBase(*contentType.split("/"))
205 mimeItem.add_header("Content-Disposition", "form-data",
206 name="file%s" % idx, filename=filename)
207 # TODO: Encode the payload?
208 mimeItem.set_payload(payload)
210 # TODO: Handle this better...?
211 for hdr in ['MIME-Version','Content-Transfer-Encoding']:
212 del mimeItem[hdr]
214 mimeMsg.attach(mimeItem)
216 del mimeMsg['MIME-Version']
218 return mimeMsg
221 class GmailLoginFailure(Exception):
223 Raised whenever the login process fails--could be wrong username/password,
224 or Gmail service error, for example.
225 Extract the error message like this:
226 try:
227 foobar
228 except GmailLoginFailure,e:
229 mesg = e.message# or
230 print e# uses the __str__
232 def __init__(self,message):
233 self.message = message
234 def __str__(self):
235 return repr(self.message)
237 class GmailAccount:
241 def __init__(self, name = "", pw = "", state = None, domain = None):
242 global URL_LOGIN, URL_GMAIL
245 self.domain = domain
246 if self.domain:
247 URL_LOGIN = "https://www.google.com/a/" + self.domain + "/LoginAction2"
248 URL_GMAIL = "http://mail.google.com/a/" + self.domain + "/?ui=1&"
250 else:
251 URL_LOGIN = GMAIL_URL_LOGIN
252 URL_GMAIL = GMAIL_URL_GMAIL
253 if name and pw:
254 self.name = name
255 self._pw = pw
257 self._cookieJar = ClientCookie.LWPCookieJar()
258 opener = ClientCookie.build_opener(ClientCookie.HTTPCookieProcessor(self._cookieJar))
259 ClientCookie.install_opener(opener)
261 if PROXY_URL is not None:
262 import gmail_transport
264 self.opener = ClientCookie.build_opener(gmail_transport.ConnectHTTPHandler(proxy = PROXY_URL),
265 gmail_transport.ConnectHTTPSHandler(proxy = PROXY_URL),
266 SmartRedirectHandler(self._cookieJar))
267 else:
268 self.opener = ClientCookie.build_opener(
269 ClientCookie.HTTPHandler(),
270 ClientCookie.HTTPSHandler(),
271 SmartRedirectHandler(self._cookieJar))
272 elif state:
273 # TODO: Check for stale state cookies?
274 self.name, self._cookieJar = state.state
275 else:
276 raise ValueError("GmailAccount must be instantiated with " \
277 "either GmailSessionState object or name " \
278 "and password.")
280 self._cachedQuotaInfo = None
281 self._cachedLabelNames = None
284 def login(self):
287 # TODO: Throw exception if we were instantiated with state?
288 if self.domain:
289 data = urllib.urlencode({'continue': URL_GMAIL,
290 'at' : 'null',
291 'service' : 'mail',
292 'Email': self.name,
293 'Passwd': self._pw,
295 else:
296 data = urllib.urlencode({'continue': URL_GMAIL,
297 'Email': self.name,
298 'Passwd': self._pw,
301 headers = {'Host': 'www.google.com',
302 'User-Agent': 'Mozilla/5.0 (Compatible; libgmail-python)'}
304 req = ClientCookie.Request(URL_LOGIN, data=data, headers=headers)
305 pageData = self._retrievePage(req)
307 if not self.domain:
308 # The GV cookie no longer comes in this page for
309 # "Apps", so this bottom portion is unnecessary for it.
310 # This requests the page that provides the required "GV" cookie.
311 RE_PAGE_REDIRECT = 'CheckCookie\?continue=([^"\']+)'
313 # TODO: Catch more failure exceptions here...?
314 try:
315 link = re.search(RE_PAGE_REDIRECT, pageData).group(1)
316 redirectURL = urllib2.unquote(link)
317 redirectURL = redirectURL.replace('\\x26', '&')
319 except AttributeError:
320 raise GmailLoginFailure("Login failed. (Wrong username/password?)")
321 # We aren't concerned with the actual content of this page,
322 # just the cookie that is returned with it.
323 pageData = self._retrievePage(redirectURL)
325 def getCookie(self,cookiename):
326 # TODO: Is there a way to extract the value directly?
327 for index, cookie in enumerate(self._cookieJar):
328 if cookie.name == cookiename:
329 return cookie.value
330 return ""
332 def _retrievePage(self, urlOrRequest):
335 if self.opener is None:
336 raise "Cannot find urlopener"
338 # ClientCookieify it, if it hasn't been already
339 if not isinstance(urlOrRequest, urllib2.Request):
340 req = ClientCookie.Request(urlOrRequest)
341 else:
342 req = urlOrRequest
344 req.add_header('User-Agent',
345 'Mozilla/5.0 (Compatible; libgmail-python)')
347 try:
348 resp = self.opener.open(req)
349 except urllib2.HTTPError,info:
350 print info
351 return None
352 pageData = resp.read()
354 # TODO: This, for some reason, is still necessary?
355 self._cookieJar.extract_cookies(resp, req)
357 # TODO: Enable logging of page data for debugging purposes?
358 return pageData
360 def _parsePage(self, urlOrRequest):
362 Retrieve & then parse the requested page content.
365 items = _parsePage(self._retrievePage(urlOrRequest))
366 # Automatically cache some things like quota usage.
367 # TODO: Cache more?
368 # TODO: Expire cached values?
369 # TODO: Do this better.
370 try:
371 self._cachedQuotaInfo = items[D_QUOTA]
372 except KeyError:
373 pass
374 #pprint.pprint(items)
376 try:
377 self._cachedLabelNames = [category[CT_NAME] for category in items[D_CATEGORIES][0]]
378 except KeyError:
379 pass
381 return items
384 def _parseSearchResult(self, searchType, start = 0, **kwargs):
387 params = {U_SEARCH: searchType,
388 U_START: start,
389 U_VIEW: U_THREADLIST_VIEW,
391 params.update(kwargs)
392 return self._parsePage(_buildURL(**params))
395 def _parseThreadSearch(self, searchType, allPages = False, **kwargs):
398 Only works for thread-based results at present. # TODO: Change this?
400 start = 0
401 tot = 0
402 threadsInfo = []
403 # Option to get *all* threads if multiple pages are used.
404 while (start == 0) or (allPages and
405 len(threadsInfo) < threadListSummary[TS_TOTAL]):
407 items = self._parseSearchResult(searchType, start, **kwargs)
408 #TODO: Handle single & zero result case better? Does this work?
409 try:
410 threads = items[D_THREAD]
411 except KeyError:
412 break
413 else:
414 for th in threads:
415 if not type(th[0]) is types.ListType:
416 th = [th]
417 threadsInfo.append(th)
418 # TODO: Check if the total or per-page values have changed?
419 threadListSummary = items[D_THREADLIST_SUMMARY][0]
420 threadsPerPage = threadListSummary[TS_NUM]
422 start += threadsPerPage
424 # TODO: Record whether or not we retrieved all pages..?
425 return GmailSearchResult(self, (searchType, kwargs), threadsInfo)
428 def _retrieveJavascript(self, version = ""):
431 Note: `version` seems to be ignored.
433 return self._retrievePage(_buildURL(view = U_PAGE_VIEW,
434 name = "js",
435 ver = version))
438 def getMessagesByFolder(self, folderName, allPages = False):
441 Folders contain conversation/message threads.
443 `folderName` -- As set in Gmail interface.
445 Returns a `GmailSearchResult` instance.
447 *** TODO: Change all "getMessagesByX" to "getThreadsByX"? ***
449 return self._parseThreadSearch(folderName, allPages = allPages)
452 def getMessagesByQuery(self, query, allPages = False):
455 Returns a `GmailSearchResult` instance.
457 return self._parseThreadSearch(U_QUERY_SEARCH, q = query,
458 allPages = allPages)
461 def getQuotaInfo(self, refresh = False):
464 Return MB used, Total MB and percentage used.
466 # TODO: Change this to a property.
467 if not self._cachedQuotaInfo or refresh:
468 # TODO: Handle this better...
469 self.getMessagesByFolder(U_INBOX_SEARCH)
471 return self._cachedQuotaInfo[0][:3]
474 def getLabelNames(self, refresh = False):
477 # TODO: Change this to a property?
478 if not self._cachedLabelNames or refresh:
479 # TODO: Handle this better...
480 self.getMessagesByFolder(U_INBOX_SEARCH)
482 return self._cachedLabelNames
485 def getMessagesByLabel(self, label, allPages = False):
488 return self._parseThreadSearch(U_CATEGORY_SEARCH,
489 cat=label, allPages = allPages)
491 def getRawMessage(self, msgId):
494 # U_ORIGINAL_MESSAGE_VIEW seems the only one that returns a page.
495 # All the other U_* results in a 404 exception. Stas
496 PageView = U_ORIGINAL_MESSAGE_VIEW
497 return self._retrievePage(
498 _buildURL(view=PageView, th=msgId))
500 def getUnreadMessages(self):
503 return self._parseThreadSearch(U_QUERY_SEARCH,
504 q = "is:" + U_AS_SUBSET_UNREAD)
507 def getUnreadMsgCount(self):
510 items = self._parseSearchResult(U_QUERY_SEARCH,
511 q = "is:" + U_AS_SUBSET_UNREAD)
512 try:
513 result = items[D_THREADLIST_SUMMARY][0][TS_TOTAL_MSGS]
514 except KeyError:
515 result = 0
516 return result
519 def _getActionToken(self):
522 try:
523 at = self.getCookie(ACTION_TOKEN_COOKIE)
524 except KeyError:
525 self.getLabelNames(True)
526 at = self.getCookie(ACTION_TOKEN_COOKIE)
528 return at
531 def sendMessage(self, msg, asDraft = False, _extraParams = None):
534 `msg` -- `GmailComposedMessage` instance.
536 `_extraParams` -- Dictionary containing additional parameters
537 to put into POST message. (Not officially
538 for external use, more to make feature
539 additional a little easier to play with.)
541 Note: Now returns `GmailMessageStub` instance with populated
542 `id` (and `_account`) fields on success or None on failure.
545 # TODO: Handle drafts separately?
546 params = {U_VIEW: [U_SENDMAIL_VIEW, U_SAVEDRAFT_VIEW][asDraft],
547 U_REFERENCED_MSG: "",
548 U_THREAD: "",
549 U_DRAFT_MSG: "",
550 U_COMPOSEID: "1",
551 U_ACTION_TOKEN: self._getActionToken(),
552 U_COMPOSE_TO: msg.to,
553 U_COMPOSE_CC: msg.cc,
554 U_COMPOSE_BCC: msg.bcc,
555 "subject": msg.subject,
556 "msgbody": msg.body,
559 if _extraParams:
560 params.update(_extraParams)
562 # Amongst other things, I used the following post to work out this:
563 # <http://groups.google.com/groups?
564 # selm=mailman.1047080233.20095.python-list%40python.org>
565 mimeMessage = _paramsToMime(params, msg.filenames, msg.files)
567 #### TODO: Ughh, tidy all this up & do it better...
568 ## This horrible mess is here for two main reasons:
569 ## 1. The `Content-Type` header (which also contains the boundary
570 ## marker) needs to be extracted from the MIME message so
571 ## we can send it as the request `Content-Type` header instead.
572 ## 2. It seems the form submission needs to use "\r\n" for new
573 ## lines instead of the "\n" returned by `as_string()`.
574 ## I tried changing the value of `NL` used by the `Generator` class
575 ## but it didn't work so I'm doing it this way until I figure
576 ## out how to do it properly. Of course, first try, if the payloads
577 ## contained "\n" sequences they got replaced too, which corrupted
578 ## the attachments. I could probably encode the submission,
579 ## which would probably be nicer, but in the meantime I'm kludging
580 ## this workaround that replaces all non-text payloads with a
581 ## marker, changes all "\n" to "\r\n" and finally replaces the
582 ## markers with the original payloads.
583 ## Yeah, I know, it's horrible, but hey it works doesn't it? If you've
584 ## got a problem with it, fix it yourself & give me the patch!
586 origPayloads = {}
587 FMT_MARKER = "&&&&&&%s&&&&&&"
589 for i, m in enumerate(mimeMessage.get_payload()):
590 if not isinstance(m, MIMEText): #Do we care if we change text ones?
591 origPayloads[i] = m.get_payload()
592 m.set_payload(FMT_MARKER % i)
594 mimeMessage.epilogue = ""
595 msgStr = mimeMessage.as_string()
596 contentTypeHeader, data = msgStr.split("\n\n", 1)
597 contentTypeHeader = contentTypeHeader.split(":", 1)
598 data = data.replace("\n", "\r\n")
599 for k,v in origPayloads.iteritems():
600 data = data.replace(FMT_MARKER % k, v)
601 ####
603 req = ClientCookie.Request(_buildURL(), data = data)
604 req.add_header(*contentTypeHeader)
605 items = self._parsePage(req)
607 # TODO: Check composeid?
608 # Sometimes we get the success message
609 # but the id is 0 and no message is sent
610 result = None
611 resultInfo = items[D_SENDMAIL_RESULT][0]
613 if resultInfo[SM_SUCCESS]:
614 result = GmailMessageStub(id = resultInfo[SM_NEWTHREADID],
615 _account = self)
616 else:
617 raise GmailSendError, resultInfo[SM_MSG]
618 return result
621 def trashMessage(self, msg):
624 # TODO: Decide if we should make this a method of `GmailMessage`.
625 # TODO: Should we check we have been given a `GmailMessage` instance?
626 params = {
627 U_ACTION: U_DELETEMESSAGE_ACTION,
628 U_ACTION_MESSAGE: msg.id,
629 U_ACTION_TOKEN: self._getActionToken(),
632 items = self._parsePage(_buildURL(**params))
634 # TODO: Mark as trashed on success?
635 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
638 def _doThreadAction(self, actionId, thread):
641 # TODO: Decide if we should make this a method of `GmailThread`.
642 # TODO: Should we check we have been given a `GmailThread` instance?
643 params = {
644 U_SEARCH: U_ALL_SEARCH, #TODO:Check this search value always works.
645 U_VIEW: U_UPDATE_VIEW,
646 U_ACTION: actionId,
647 U_ACTION_THREAD: thread.id,
648 U_ACTION_TOKEN: self._getActionToken(),
651 items = self._parsePage(_buildURL(**params))
653 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
656 def trashThread(self, thread):
659 # TODO: Decide if we should make this a method of `GmailThread`.
660 # TODO: Should we check we have been given a `GmailThread` instance?
662 result = self._doThreadAction(U_MARKTRASH_ACTION, thread)
664 # TODO: Mark as trashed on success?
665 return result
668 def _createUpdateRequest(self, actionId): #extraData):
670 Helper method to create a Request instance for an update (view)
671 action.
673 Returns populated `Request` instance.
675 params = {
676 U_VIEW: U_UPDATE_VIEW,
679 data = {
680 U_ACTION: actionId,
681 U_ACTION_TOKEN: self._getActionToken(),
684 #data.update(extraData)
686 req = ClientCookie.Request(_buildURL(**params),
687 data = urllib.urlencode(data))
689 return req
692 # TODO: Extract additional common code from handling of labels?
693 def createLabel(self, labelName):
696 req = self._createUpdateRequest(U_CREATECATEGORY_ACTION + labelName)
698 # Note: Label name cache is updated by this call as well. (Handy!)
699 items = self._parsePage(req)
700 print items
701 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
704 def deleteLabel(self, labelName):
707 # TODO: Check labelName exits?
708 req = self._createUpdateRequest(U_DELETECATEGORY_ACTION + labelName)
710 # Note: Label name cache is updated by this call as well. (Handy!)
711 items = self._parsePage(req)
713 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
716 def renameLabel(self, oldLabelName, newLabelName):
719 # TODO: Check oldLabelName exits?
720 req = self._createUpdateRequest("%s%s^%s" % (U_RENAMECATEGORY_ACTION,
721 oldLabelName, newLabelName))
723 # Note: Label name cache is updated by this call as well. (Handy!)
724 items = self._parsePage(req)
726 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
728 def storeFile(self, filename, label = None):
731 # TODO: Handle files larger than single attachment size.
732 # TODO: Allow file data objects to be supplied?
733 FILE_STORE_VERSION = "FSV_01"
734 FILE_STORE_SUBJECT_TEMPLATE = "%s %s" % (FILE_STORE_VERSION, "%s")
736 subject = FILE_STORE_SUBJECT_TEMPLATE % os.path.basename(filename)
738 msg = GmailComposedMessage(to="", subject=subject, body="",
739 filenames=[filename])
741 draftMsg = self.sendMessage(msg, asDraft = True)
743 if draftMsg and label:
744 draftMsg.addLabel(label)
746 return draftMsg
748 ## CONTACTS SUPPORT
749 def getContacts(self):
751 Returns a GmailContactList object
752 that has all the contacts in it as
753 GmailContacts
755 contactList = []
756 # pnl = a is necessary to get *all* contacts
757 myUrl = _buildURL(view='cl',search='contacts', pnl='a')
758 myData = self._parsePage(myUrl)
759 # This comes back with a dictionary
760 # with entry 'cl'
761 addresses = myData['cl']
762 for entry in addresses:
763 if len(entry) >= 6 and entry[0]=='ce':
764 newGmailContact = GmailContact(entry[1], entry[2], entry[4], entry[5])
765 #### new code used to get all the notes
766 #### not used yet due to lockdown problems
767 ##rawnotes = self._getSpecInfo(entry[1])
768 ##print rawnotes
769 ##newGmailContact = GmailContact(entry[1], entry[2], entry[4],rawnotes)
770 contactList.append(newGmailContact)
772 return GmailContactList(contactList)
774 def addContact(self, myContact, *extra_args):
776 Attempts to add a GmailContact to the gmail
777 address book. Returns true if successful,
778 false otherwise
780 Please note that after version 0.1.3.3,
781 addContact takes one argument of type
782 GmailContact, the contact to add.
784 The old signature of:
785 addContact(name, email, notes='') is still
786 supported, but deprecated.
788 if len(extra_args) > 0:
789 # The user has passed in extra arguments
790 # He/she is probably trying to invoke addContact
791 # using the old, deprecated signature of:
792 # addContact(self, name, email, notes='')
793 # Build a GmailContact object and use that instead
794 (name, email) = (myContact, extra_args[0])
795 if len(extra_args) > 1:
796 notes = extra_args[1]
797 else:
798 notes = ''
799 myContact = GmailContact(-1, name, email, notes)
801 # TODO: In the ideal world, we'd extract these specific
802 # constants into a nice constants file
804 # This mostly comes from the Johnvey Gmail API,
805 # but also from the gmail.py cited earlier
806 myURL = _buildURL(view='up')
808 myDataList = [ ('act','ec'),
809 ('at', self.getCookie(ACTION_TOKEN_COOKIE)),
810 ('ct_nm', myContact.getName()),
811 ('ct_em', myContact.getEmail()),
812 ('ct_id', -1 )
815 notes = myContact.getNotes()
816 if notes != '':
817 myDataList.append( ('ctf_n', notes) )
819 validinfokeys = [
820 'i', # IM
821 'p', # Phone
822 'd', # Company
823 'a', # ADR
824 'e', # Email
825 'm', # Mobile
826 'b', # Pager
827 'f', # Fax
828 't', # Title
829 'o', # Other
832 moreInfo = myContact.getMoreInfo()
833 ctsn_num = -1
834 if moreInfo != {}:
835 for ctsf,ctsf_data in moreInfo.items():
836 ctsn_num += 1
837 # data section header, WORK, HOME,...
838 sectionenum ='ctsn_%02d' % ctsn_num
839 myDataList.append( ( sectionenum, ctsf ))
840 ctsf_num = -1
842 if isinstance(ctsf_data[0],str):
843 ctsf_num += 1
844 # data section
845 subsectionenum = 'ctsf_%02d_%02d_%s' % (ctsn_num, ctsf_num, ctsf_data[0]) # ie. ctsf_00_01_p
846 myDataList.append( (subsectionenum, ctsf_data[1]) )
847 else:
848 for info in ctsf_data:
849 if validinfokeys.count(info[0]) > 0:
850 ctsf_num += 1
851 # data section
852 subsectionenum = 'ctsf_%02d_%02d_%s' % (ctsn_num, ctsf_num, info[0]) # ie. ctsf_00_01_p
853 myDataList.append( (subsectionenum, info[1]) )
855 myData = urllib.urlencode(myDataList)
856 request = ClientCookie.Request(myURL,
857 data = myData)
858 pageData = self._retrievePage(request)
860 if pageData.find("The contact was successfully added") == -1:
861 print pageData
862 if pageData.find("already has the email address") > 0:
863 raise Exception("Someone with same email already exists in Gmail.")
864 elif pageData.find("https://www.google.com/accounts/ServiceLogin"):
865 raise Exception("Login has expired.")
866 return False
867 else:
868 return True
870 def _removeContactById(self, id):
872 Attempts to remove the contact that occupies
873 id "id" from the gmail address book.
874 Returns True if successful,
875 False otherwise.
877 This is a little dangerous since you don't really
878 know who you're deleting. Really,
879 this should return the name or something of the
880 person we just killed.
882 Don't call this method.
883 You should be using removeContact instead.
885 myURL = _buildURL(search='contacts', ct_id = id, c=id, act='dc', at=self.getCookie(ACTION_TOKEN_COOKIE), view='up')
886 pageData = self._retrievePage(myURL)
888 if pageData.find("The contact has been deleted") == -1:
889 return False
890 else:
891 return True
893 def removeContact(self, gmailContact):
895 Attempts to remove the GmailContact passed in
896 Returns True if successful, False otherwise.
898 # Let's re-fetch the contact list to make
899 # sure we're really deleting the guy
900 # we think we're deleting
901 newContactList = self.getContacts()
902 newVersionOfPersonToDelete = newContactList.getContactById(gmailContact.getId())
903 # Ok, now we need to ensure that gmailContact
904 # is the same as newVersionOfPersonToDelete
905 # and then we can go ahead and delete him/her
906 if (gmailContact == newVersionOfPersonToDelete):
907 return self._removeContactById(gmailContact.getId())
908 else:
909 # We have a cache coherency problem -- someone
910 # else now occupies this ID slot.
911 # TODO: Perhaps signal this in some nice way
912 # to the end user?
914 print "Unable to delete."
915 print "Has someone else been modifying the contacts list while we have?"
916 print "Old version of person:",gmailContact
917 print "New version of person:",newVersionOfPersonToDelete
918 return False
920 ## Don't remove this. contact stas
921 ## def _getSpecInfo(self,id):
922 ## """
923 ## Return all the notes data.
924 ## This is currently not used due to the fact that it requests pages in
925 ## a dos attack manner.
926 ## """
927 ## myURL =_buildURL(search='contacts',ct_id=id,c=id,\
928 ## at=self._cookieJar._cookies['GMAIL_AT'],view='ct')
929 ## pageData = self._retrievePage(myURL)
930 ## myData = self._parsePage(myURL)
931 ## #print "\nmyData form _getSpecInfo\n",myData
932 ## rawnotes = myData['cov'][7]
933 ## return rawnotes
935 class GmailContact:
937 Class for storing a Gmail Contacts list entry
939 def __init__(self, name, email, *extra_args):
941 Returns a new GmailContact object
942 (you can then call addContact on this to commit
943 it to the Gmail addressbook, for example)
945 Consider calling setNotes() and setMoreInfo()
946 to add extended information to this contact
948 # Support populating other fields if we're trying
949 # to invoke this the old way, with the old constructor
950 # whose signature was __init__(self, id, name, email, notes='')
951 id = -1
952 notes = ''
954 if len(extra_args) > 0:
955 (id, name) = (name, email)
956 email = extra_args[0]
957 if len(extra_args) > 1:
958 notes = extra_args[1]
959 else:
960 notes = ''
962 self.id = id
963 self.name = name
964 self.email = email
965 self.notes = notes
966 self.moreInfo = {}
967 def __str__(self):
968 return "%s %s %s %s" % (self.id, self.name, self.email, self.notes)
969 def __eq__(self, other):
970 if not isinstance(other, GmailContact):
971 return False
972 return (self.getId() == other.getId()) and \
973 (self.getName() == other.getName()) and \
974 (self.getEmail() == other.getEmail()) and \
975 (self.getNotes() == other.getNotes())
976 def getId(self):
977 return self.id
978 def getName(self):
979 return self.name
980 def getEmail(self):
981 return self.email
982 def getNotes(self):
983 return self.notes
984 def setNotes(self, notes):
986 Sets the notes field for this GmailContact
987 Note that this does NOT change the note
988 field on Gmail's end; only adding or removing
989 contacts modifies them
991 self.notes = notes
993 def getMoreInfo(self):
994 return self.moreInfo
995 def setMoreInfo(self, moreInfo):
997 moreInfo format
998 ---------------
999 Use special key values::
1000 'i' = IM
1001 'p' = Phone
1002 'd' = Company
1003 'a' = ADR
1004 'e' = Email
1005 'm' = Mobile
1006 'b' = Pager
1007 'f' = Fax
1008 't' = Title
1009 'o' = Other
1011 Simple example::
1013 moreInfo = {'Home': ( ('a','852 W Barry'),
1014 ('p', '1-773-244-1980'),
1015 ('i', 'aim:brianray34') ) }
1017 Complex example::
1019 moreInfo = {
1020 'Personal': (('e', 'Home Email'),
1021 ('f', 'Home Fax')),
1022 'Work': (('d', 'Sample Company'),
1023 ('t', 'Job Title'),
1024 ('o', 'Department: Department1'),
1025 ('o', 'Department: Department2'),
1026 ('p', 'Work Phone'),
1027 ('m', 'Mobile Phone'),
1028 ('f', 'Work Fax'),
1029 ('b', 'Pager')) }
1031 self.moreInfo = moreInfo
1032 def getVCard(self):
1033 """Returns a vCard 3.0 for this
1034 contact, as a string"""
1035 # The \r is is to comply with the RFC2425 section 5.8.1
1036 vcard = "BEGIN:VCARD\r\n"
1037 vcard += "VERSION:3.0\r\n"
1038 ## Deal with multiline notes
1039 ##vcard += "NOTE:%s\n" % self.getNotes().replace("\n","\\n")
1040 vcard += "NOTE:%s\r\n" % self.getNotes()
1041 # Fake-out N by splitting up whatever we get out of getName
1042 # This might not always do 'the right thing'
1043 # but it's a *reasonable* compromise
1044 fullname = self.getName().split()
1045 fullname.reverse()
1046 vcard += "N:%s" % ';'.join(fullname) + "\r\n"
1047 vcard += "FN:%s\r\n" % self.getName()
1048 vcard += "EMAIL;TYPE=INTERNET:%s\r\n" % self.getEmail()
1049 vcard += "END:VCARD\r\n\r\n"
1050 # Final newline in case we want to put more than one in a file
1051 return vcard
1053 class GmailContactList:
1055 Class for storing an entire Gmail contacts list
1056 and retrieving contacts by Id, Email address, and name
1058 def __init__(self, contactList):
1059 self.contactList = contactList
1060 def __str__(self):
1061 return '\n'.join([str(item) for item in self.contactList])
1062 def getCount(self):
1064 Returns number of contacts
1066 return len(self.contactList)
1067 def getAllContacts(self):
1069 Returns an array of all the
1070 GmailContacts
1072 return self.contactList
1073 def getContactByName(self, name):
1075 Gets the first contact in the
1076 address book whose name is 'name'.
1078 Returns False if no contact
1079 could be found
1081 nameList = self.getContactListByName(name)
1082 if len(nameList) > 0:
1083 return nameList[0]
1084 else:
1085 return False
1086 def getContactByEmail(self, email):
1088 Gets the first contact in the
1089 address book whose name is 'email'.
1090 As of this writing, Gmail insists
1091 upon a unique email; i.e. two contacts
1092 cannot share an email address.
1094 Returns False if no contact
1095 could be found
1097 emailList = self.getContactListByEmail(email)
1098 if len(emailList) > 0:
1099 return emailList[0]
1100 else:
1101 return False
1102 def getContactById(self, myId):
1104 Gets the first contact in the
1105 address book whose id is 'myId'.
1107 REMEMBER: ID IS A STRING
1109 Returns False if no contact
1110 could be found
1112 idList = self.getContactListById(myId)
1113 if len(idList) > 0:
1114 return idList[0]
1115 else:
1116 return False
1117 def getContactListByName(self, name):
1119 This function returns a LIST
1120 of GmailContacts whose name is
1121 'name'.
1123 Returns an empty list if no contacts
1124 were found
1126 nameList = []
1127 for entry in self.contactList:
1128 if entry.getName() == name:
1129 nameList.append(entry)
1130 return nameList
1131 def getContactListByEmail(self, email):
1133 This function returns a LIST
1134 of GmailContacts whose email is
1135 'email'. As of this writing, two contacts
1136 cannot share an email address, so this
1137 should only return just one item.
1138 But it doesn't hurt to be prepared?
1140 Returns an empty list if no contacts
1141 were found
1143 emailList = []
1144 for entry in self.contactList:
1145 if entry.getEmail() == email:
1146 emailList.append(entry)
1147 return emailList
1148 def getContactListById(self, myId):
1150 This function returns a LIST
1151 of GmailContacts whose id is
1152 'myId'. We expect there only to
1153 be one, but just in case!
1155 Remember: ID IS A STRING
1157 Returns an empty list if no contacts
1158 were found
1160 idList = []
1161 for entry in self.contactList:
1162 if entry.getId() == myId:
1163 idList.append(entry)
1164 return idList
1165 def search(self, searchTerm):
1167 This function returns a LIST
1168 of GmailContacts whose name or
1169 email address matches the 'searchTerm'.
1171 Returns an empty list if no matches
1172 were found.
1174 searchResults = []
1175 for entry in self.contactList:
1176 p = re.compile(searchTerm, re.IGNORECASE)
1177 if p.search(entry.getName()) or p.search(entry.getEmail()):
1178 searchResults.append(entry)
1179 return searchResults
1181 class GmailSearchResult:
1185 def __init__(self, account, search, threadsInfo):
1188 `threadsInfo` -- As returned from Gmail but unbunched.
1190 #print "\nthreadsInfo\n",threadsInfo
1191 try:
1192 if not type(threadsInfo[0]) is types.ListType:
1193 threadsInfo = [threadsInfo]
1194 except IndexError:
1195 # print "No messages found"
1196 pass
1198 self._account = account
1199 self.search = search # TODO: Turn into object + format nicely.
1200 self._threads = []
1202 for thread in threadsInfo:
1203 self._threads.append(GmailThread(self, thread[0]))
1206 def __iter__(self):
1209 return iter(self._threads)
1211 def __len__(self):
1214 return len(self._threads)
1216 def __getitem__(self,key):
1219 return self._threads.__getitem__(key)
1222 class GmailSessionState:
1226 def __init__(self, account = None, filename = ""):
1229 if account:
1230 self.state = (account.name, account._cookieJar)
1231 elif filename:
1232 self.state = load(open(filename, "rb"))
1233 else:
1234 raise ValueError("GmailSessionState must be instantiated with " \
1235 "either GmailAccount object or filename.")
1238 def save(self, filename):
1241 dump(self.state, open(filename, "wb"), -1)
1244 class _LabelHandlerMixin(object):
1247 Note: Because a message id can be used as a thread id this works for
1248 messages as well as threads.
1250 def __init__(self):
1251 self._labels = None
1253 def _makeLabelList(self, labelList):
1254 self._labels = labelList
1256 def addLabel(self, labelName):
1259 # Note: It appears this also automatically creates new labels.
1260 result = self._account._doThreadAction(U_ADDCATEGORY_ACTION+labelName,
1261 self)
1262 if not self._labels:
1263 self._makeLabelList([])
1264 # TODO: Caching this seems a little dangerous; suppress duplicates maybe?
1265 self._labels.append(labelName)
1266 return result
1269 def removeLabel(self, labelName):
1272 # TODO: Check label is already attached?
1273 # Note: An error is not generated if the label is not already attached.
1274 result = \
1275 self._account._doThreadAction(U_REMOVECATEGORY_ACTION+labelName,
1276 self)
1278 removeLabel = True
1279 try:
1280 self._labels.remove(labelName)
1281 except:
1282 removeLabel = False
1283 pass
1285 # If we don't check both, we might end up in some weird inconsistent state
1286 return result and removeLabel
1288 def getLabels(self):
1289 return self._labels
1293 class GmailThread(_LabelHandlerMixin):
1295 Note: As far as I can tell, the "canonical" thread id is always the same
1296 as the id of the last message in the thread. But it appears that
1297 the id of any message in the thread can be used to retrieve
1298 the thread information.
1302 def __init__(self, parent, threadsInfo):
1305 _LabelHandlerMixin.__init__(self)
1307 # TODO Handle this better?
1308 self._parent = parent
1309 self._account = self._parent._account
1311 self.id = threadsInfo[T_THREADID] # TODO: Change when canonical updated?
1312 self.subject = threadsInfo[T_SUBJECT_HTML]
1314 self.snippet = threadsInfo[T_SNIPPET_HTML]
1315 #self.extraSummary = threadInfo[T_EXTRA_SNIPPET] #TODO: What is this?
1317 # TODO: Store other info?
1318 # Extract number of messages in thread/conversation.
1320 self._authors = threadsInfo[T_AUTHORS_HTML]
1321 self.info = threadsInfo
1323 try:
1324 # TODO: Find out if this information can be found another way...
1325 # (Without another page request.)
1326 self._length = int(re.search("\((\d+?)\)\Z",
1327 self._authors).group(1))
1328 except AttributeError,info:
1329 # If there's no message count then the thread only has one message.
1330 self._length = 1
1332 # TODO: Store information known about the last message (e.g. id)?
1333 self._messages = []
1335 # Populate labels
1336 self._makeLabelList(threadsInfo[T_CATEGORIES])
1338 def __getattr__(self, name):
1340 Dynamically dispatch some interesting thread properties.
1342 attrs = { 'unread': T_UNREAD,
1343 'star': T_STAR,
1344 'date': T_DATE_HTML,
1345 'authors': T_AUTHORS_HTML,
1346 'flags': T_FLAGS,
1347 'subject': T_SUBJECT_HTML,
1348 'snippet': T_SNIPPET_HTML,
1349 'categories': T_CATEGORIES,
1350 'attach': T_ATTACH_HTML,
1351 'matching_msgid': T_MATCHING_MSGID,
1352 'extra_snippet': T_EXTRA_SNIPPET }
1353 if name in attrs:
1354 return self.info[ attrs[name] ];
1356 raise AttributeError("no attribute %s" % name)
1358 def __len__(self):
1361 return self._length
1364 def __iter__(self):
1367 if not self._messages:
1368 self._messages = self._getMessages(self)
1370 yit = self._messages
1371 self._messages = None
1372 return iter(yit)
1374 def __getitem__(self, key):
1377 if not self._messages:
1378 self._messages = self._getMessages(self)
1379 try:
1380 result = self._messages.__getitem__(key)
1381 except IndexError:
1382 result = []
1383 return result
1385 def _getMessages(self, thread):
1388 # TODO: Do this better.
1389 # TODO: Specify the query folder using our specific search?
1390 items = self._account._parseSearchResult(U_QUERY_SEARCH,
1391 view = U_CONVERSATION_VIEW,
1392 th = thread.id,
1393 q = "in:anywhere")
1394 result = []
1395 # TODO: Handle this better?
1396 # Note: This handles both draft & non-draft messages in a thread...
1397 for key, isDraft in [(D_MSGINFO, False), (D_DRAFTINFO, True)]:
1398 try:
1399 msgsInfo = items[key]
1400 except KeyError:
1401 # No messages of this type (e.g. draft or non-draft)
1402 continue
1403 else:
1404 # TODO: Handle special case of only 1 message in thread better?
1405 if type(msgsInfo[0]) != types.ListType:
1406 msgsInfo = [msgsInfo]
1407 for msg in msgsInfo:
1408 result += [GmailMessage(thread, msg, isDraft = isDraft)]
1410 return result
1412 class GmailMessageStub(_LabelHandlerMixin):
1415 Intended to be used where not all message information is known/required.
1417 NOTE: This may go away.
1420 # TODO: Provide way to convert this to a full `GmailMessage` instance
1421 # or allow `GmailMessage` to be created without all info?
1423 def __init__(self, id = None, _account = None):
1426 _LabelHandlerMixin.__init__(self)
1427 self.id = id
1428 self._account = _account
1432 class GmailMessage(object):
1436 def __init__(self, parent, msgData, isDraft = False):
1439 Note: `msgData` can be from either D_MSGINFO or D_DRAFTINFO.
1441 # TODO: Automatically detect if it's a draft or not?
1442 # TODO Handle this better?
1443 self._parent = parent
1444 self._account = self._parent._account
1446 self.author = msgData[MI_AUTHORFIRSTNAME]
1447 self.author_fullname = msgData[MI_AUTHORNAME]
1448 self.id = msgData[MI_MSGID]
1449 self.number = msgData[MI_NUM]
1450 self.subject = msgData[MI_SUBJECT]
1451 self.to = [x.decode('utf-8') for x in msgData[MI_TO]]
1452 self.cc = [x.decode('utf-8') for x in msgData[MI_CC]]
1453 self.bcc = [x.decode('utf-8') for x in msgData[MI_BCC]]
1454 self.sender = msgData[MI_AUTHOREMAIL]
1456 # Messages created by google chat (from reply with chat, etc.)
1457 # don't have any attachments, so we need this check not to choke
1458 # on them
1459 try:
1460 self.attachments = [GmailAttachment(self, attachmentInfo)
1461 for attachmentInfo in msgData[MI_ATTACHINFO]]
1462 except TypeError:
1463 self.attachments = []
1465 # TODO: Populate additional fields & cache...(?)
1467 # TODO: Handle body differently if it's from a draft?
1468 self.isDraft = isDraft
1470 self._source = None
1473 def _getSource(self):
1476 if not self._source:
1477 # TODO: Do this more nicely...?
1478 # TODO: Strip initial white space & fix up last line ending
1479 # to make it legal as per RFC?
1480 self._source = self._account.getRawMessage(self.id)
1482 return self._source.decode('utf-8')
1484 source = property(_getSource, doc = "")
1488 class GmailAttachment:
1492 def __init__(self, parent, attachmentInfo):
1495 # TODO Handle this better?
1496 self._parent = parent
1497 self._account = self._parent._account
1499 self.id = attachmentInfo[A_ID]
1500 self.filename = attachmentInfo[A_FILENAME]
1501 self.mimetype = attachmentInfo[A_MIMETYPE]
1502 self.filesize = attachmentInfo[A_FILESIZE]
1504 self._content = None
1507 def _getContent(self):
1510 return self._account._retrievePage(
1511 _buildURL(view=U_ATTACHMENT_VIEW, disp="attd",
1512 attid=self.id, th=self._parent._parent.id))
1514 content = property(_getContent, doc = "")
1517 def _getFullId(self):
1520 Returns the "full path"/"full id" of the attachment. (Used
1521 to refer to the file when forwarding.)
1523 The id is of the form: "<thread_id>_<msg_id>_<attachment_id>"
1526 return "%s_%s_%s" % (self._parent._parent.id,
1527 self._parent.id,
1528 self.id)
1530 _fullId = property(_getFullId, doc = "")
1534 class GmailComposedMessage:
1538 def __init__(self, to, subject, body, cc = None, bcc = None,
1539 filenames = None, files = None):
1542 `filenames` - list of the file paths of the files to attach.
1543 `files` - list of objects implementing sub-set of
1544 `email.Message.Message` interface (`get_filename`,
1545 `get_content_type`, `get_payload`). This is to
1546 allow use of payloads from Message instances.
1547 TODO: Change this to be simpler class we define ourselves?
1549 self.to = to
1550 self.subject = subject
1551 self.body = body
1552 self.cc = cc
1553 self.bcc = bcc
1554 self.filenames = filenames
1555 self.files = files
1559 if __name__ == "__main__":
1560 import sys
1561 from getpass import getpass
1563 try:
1564 name = sys.argv[1]
1565 except IndexError:
1566 name = raw_input("Gmail account name: ")
1568 pw = getpass("Password: ")
1569 domain = raw_input("Domain? [leave blank for Gmail]: ")
1571 ga = GmailAccount(name, pw, domain=domain)
1573 print "\nPlease wait, logging in..."
1575 try:
1576 ga.login()
1577 except GmailLoginFailure,e:
1578 print "\nLogin failed. (%s)" % e.message
1579 else:
1580 print "Login successful.\n"
1582 # TODO: Use properties instead?
1583 quotaInfo = ga.getQuotaInfo()
1584 quotaMbUsed = quotaInfo[QU_SPACEUSED]
1585 quotaMbTotal = quotaInfo[QU_QUOTA]
1586 quotaPercent = quotaInfo[QU_PERCENT]
1587 print "%s of %s used. (%s)\n" % (quotaMbUsed, quotaMbTotal, quotaPercent)
1589 searches = STANDARD_FOLDERS + ga.getLabelNames()
1590 name = None
1591 while 1:
1592 try:
1593 print "Select folder or label to list: (Ctrl-C to exit)"
1594 for optionId, optionName in enumerate(searches):
1595 print " %d. %s" % (optionId, optionName)
1596 while not name:
1597 try:
1598 name = searches[int(raw_input("Choice: "))]
1599 except ValueError,info:
1600 print info
1601 name = None
1602 if name in STANDARD_FOLDERS:
1603 result = ga.getMessagesByFolder(name, True)
1604 else:
1605 result = ga.getMessagesByLabel(name, True)
1607 if not len(result):
1608 print "No threads found in `%s`." % name
1609 break
1610 name = None
1611 tot = len(result)
1613 i = 0
1614 for thread in result:
1615 print "%s messages in thread" % len(thread)
1616 print thread.id, len(thread), thread.subject
1617 for msg in thread:
1618 print "\n ", msg.id, msg.number, msg.author,msg.subject
1619 # Just as an example of other usefull things
1620 #print " ", msg.cc, msg.bcc,msg.sender
1621 i += 1
1622 print
1623 print "number of threads:",tot
1624 print "number of messages:",i
1625 except KeyboardInterrupt:
1626 break
1628 print "\n\nDone."