fix that li'l spacing issue
[QuestHelper.git] / Development / libgmail.py
blob999654ea5080c1c42c9cc06940d3c91441ef4c2e
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.10' # (July 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, nodecode = 0):
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 if nodecode == 1:
359 return pageData
360 return pageData.decode('utf-8')
363 def _parsePage(self, urlOrRequest):
365 Retrieve & then parse the requested page content.
368 items = _parsePage(self._retrievePage(urlOrRequest))
369 # Automatically cache some things like quota usage.
370 # TODO: Cache more?
371 # TODO: Expire cached values?
372 # TODO: Do this better.
373 try:
374 self._cachedQuotaInfo = items[D_QUOTA]
375 except KeyError:
376 pass
377 #pprint.pprint(items)
379 try:
380 self._cachedLabelNames = [category[CT_NAME] for category in items[D_CATEGORIES][0]]
381 except KeyError:
382 pass
384 return items
387 def _parseSearchResult(self, searchType, start = 0, **kwargs):
390 params = {U_SEARCH: searchType,
391 U_START: start,
392 U_VIEW: U_THREADLIST_VIEW,
394 params.update(kwargs)
395 return self._parsePage(_buildURL(**params))
398 def _parseThreadSearch(self, searchType, allPages = False, **kwargs):
401 Only works for thread-based results at present. # TODO: Change this?
403 start = 0
404 tot = 0
405 threadsInfo = []
406 # Option to get *all* threads if multiple pages are used.
407 while (start == 0) or (allPages and
408 len(threadsInfo) < threadListSummary[TS_TOTAL]):
410 items = self._parseSearchResult(searchType, start, **kwargs)
411 #TODO: Handle single & zero result case better? Does this work?
412 try:
413 threads = items[D_THREAD]
414 except KeyError:
415 break
416 else:
417 for th in threads:
418 if not type(th[0]) is types.ListType:
419 th = [th]
420 threadsInfo.append(th)
421 # TODO: Check if the total or per-page values have changed?
422 threadListSummary = items[D_THREADLIST_SUMMARY][0]
423 threadsPerPage = threadListSummary[TS_NUM]
425 start += threadsPerPage
427 # TODO: Record whether or not we retrieved all pages..?
428 return GmailSearchResult(self, (searchType, kwargs), threadsInfo)
431 def _retrieveJavascript(self, version = ""):
434 Note: `version` seems to be ignored.
436 return self._retrievePage(_buildURL(view = U_PAGE_VIEW,
437 name = "js",
438 ver = version))
441 def getMessagesByFolder(self, folderName, allPages = False):
444 Folders contain conversation/message threads.
446 `folderName` -- As set in Gmail interface.
448 Returns a `GmailSearchResult` instance.
450 *** TODO: Change all "getMessagesByX" to "getThreadsByX"? ***
452 return self._parseThreadSearch(folderName, allPages = allPages)
455 def getMessagesByQuery(self, query, allPages = False):
458 Returns a `GmailSearchResult` instance.
460 return self._parseThreadSearch(U_QUERY_SEARCH, q = query,
461 allPages = allPages)
464 def getQuotaInfo(self, refresh = False):
467 Return MB used, Total MB and percentage used.
469 # TODO: Change this to a property.
470 if not self._cachedQuotaInfo or refresh:
471 # TODO: Handle this better...
472 self.getMessagesByFolder(U_INBOX_SEARCH)
474 return self._cachedQuotaInfo[0][:3]
477 def getLabelNames(self, refresh = False):
480 # TODO: Change this to a property?
481 if not self._cachedLabelNames or refresh:
482 # TODO: Handle this better...
483 self.getMessagesByFolder(U_INBOX_SEARCH)
485 return self._cachedLabelNames
488 def getMessagesByLabel(self, label, allPages = False):
491 return self._parseThreadSearch(U_CATEGORY_SEARCH,
492 cat=label, allPages = allPages)
494 def getRawMessage(self, msgId):
497 # U_ORIGINAL_MESSAGE_VIEW seems the only one that returns a page.
498 # All the other U_* results in a 404 exception. Stas
499 PageView = U_ORIGINAL_MESSAGE_VIEW
500 return self._retrievePage(
501 _buildURL(view=PageView, th=msgId))
503 def getUnreadMessages(self):
506 return self._parseThreadSearch(U_QUERY_SEARCH,
507 q = "is:" + U_AS_SUBSET_UNREAD)
510 def getUnreadMsgCount(self):
513 items = self._parseSearchResult(U_QUERY_SEARCH,
514 q = "is:" + U_AS_SUBSET_UNREAD)
515 try:
516 result = items[D_THREADLIST_SUMMARY][0][TS_TOTAL_MSGS]
517 except KeyError:
518 result = 0
519 return result
522 def _getActionToken(self):
525 try:
526 at = self.getCookie(ACTION_TOKEN_COOKIE)
527 except KeyError:
528 self.getLabelNames(True)
529 at = self.getCookie(ACTION_TOKEN_COOKIE)
531 return at
534 def sendMessage(self, msg, asDraft = False, _extraParams = None):
537 `msg` -- `GmailComposedMessage` instance.
539 `_extraParams` -- Dictionary containing additional parameters
540 to put into POST message. (Not officially
541 for external use, more to make feature
542 additional a little easier to play with.)
544 Note: Now returns `GmailMessageStub` instance with populated
545 `id` (and `_account`) fields on success or None on failure.
548 # TODO: Handle drafts separately?
549 params = {U_VIEW: [U_SENDMAIL_VIEW, U_SAVEDRAFT_VIEW][asDraft],
550 U_REFERENCED_MSG: "",
551 U_THREAD: "",
552 U_DRAFT_MSG: "",
553 U_COMPOSEID: "1",
554 U_ACTION_TOKEN: self._getActionToken(),
555 U_COMPOSE_TO: msg.to,
556 U_COMPOSE_CC: msg.cc,
557 U_COMPOSE_BCC: msg.bcc,
558 "subject": msg.subject,
559 "msgbody": msg.body,
562 if _extraParams:
563 params.update(_extraParams)
565 # Amongst other things, I used the following post to work out this:
566 # <http://groups.google.com/groups?
567 # selm=mailman.1047080233.20095.python-list%40python.org>
568 mimeMessage = _paramsToMime(params, msg.filenames, msg.files)
570 #### TODO: Ughh, tidy all this up & do it better...
571 ## This horrible mess is here for two main reasons:
572 ## 1. The `Content-Type` header (which also contains the boundary
573 ## marker) needs to be extracted from the MIME message so
574 ## we can send it as the request `Content-Type` header instead.
575 ## 2. It seems the form submission needs to use "\r\n" for new
576 ## lines instead of the "\n" returned by `as_string()`.
577 ## I tried changing the value of `NL` used by the `Generator` class
578 ## but it didn't work so I'm doing it this way until I figure
579 ## out how to do it properly. Of course, first try, if the payloads
580 ## contained "\n" sequences they got replaced too, which corrupted
581 ## the attachments. I could probably encode the submission,
582 ## which would probably be nicer, but in the meantime I'm kludging
583 ## this workaround that replaces all non-text payloads with a
584 ## marker, changes all "\n" to "\r\n" and finally replaces the
585 ## markers with the original payloads.
586 ## Yeah, I know, it's horrible, but hey it works doesn't it? If you've
587 ## got a problem with it, fix it yourself & give me the patch!
589 origPayloads = {}
590 FMT_MARKER = "&&&&&&%s&&&&&&"
592 for i, m in enumerate(mimeMessage.get_payload()):
593 if not isinstance(m, MIMEText): #Do we care if we change text ones?
594 origPayloads[i] = m.get_payload()
595 m.set_payload(FMT_MARKER % i)
597 mimeMessage.epilogue = ""
598 msgStr = mimeMessage.as_string()
599 contentTypeHeader, data = msgStr.split("\n\n", 1)
600 contentTypeHeader = contentTypeHeader.split(":", 1)
601 data = data.replace("\n", "\r\n")
602 for k,v in origPayloads.iteritems():
603 data = data.replace(FMT_MARKER % k, v)
604 ####
606 req = ClientCookie.Request(_buildURL(), data = data)
607 req.add_header(*contentTypeHeader)
608 items = self._parsePage(req)
610 # TODO: Check composeid?
611 # Sometimes we get the success message
612 # but the id is 0 and no message is sent
613 result = None
614 resultInfo = items[D_SENDMAIL_RESULT][0]
616 if resultInfo[SM_SUCCESS]:
617 result = GmailMessageStub(id = resultInfo[SM_NEWTHREADID],
618 _account = self)
619 else:
620 raise GmailSendError, resultInfo[SM_MSG]
621 return result
624 def trashMessage(self, msg):
627 # TODO: Decide if we should make this a method of `GmailMessage`.
628 # TODO: Should we check we have been given a `GmailMessage` instance?
629 params = {
630 U_ACTION: U_DELETEMESSAGE_ACTION,
631 U_ACTION_MESSAGE: msg.id,
632 U_ACTION_TOKEN: self._getActionToken(),
635 items = self._parsePage(_buildURL(**params))
637 # TODO: Mark as trashed on success?
638 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
641 def _doThreadAction(self, actionId, thread):
644 # TODO: Decide if we should make this a method of `GmailThread`.
645 # TODO: Should we check we have been given a `GmailThread` instance?
646 params = {
647 U_SEARCH: U_ALL_SEARCH, #TODO:Check this search value always works.
648 U_VIEW: U_UPDATE_VIEW,
649 U_ACTION: actionId,
650 U_ACTION_THREAD: thread.id,
651 U_ACTION_TOKEN: self._getActionToken(),
654 items = self._parsePage(_buildURL(**params))
656 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
659 def trashThread(self, thread):
662 # TODO: Decide if we should make this a method of `GmailThread`.
663 # TODO: Should we check we have been given a `GmailThread` instance?
665 result = self._doThreadAction(U_MARKTRASH_ACTION, thread)
667 # TODO: Mark as trashed on success?
668 return result
671 def _createUpdateRequest(self, actionId): #extraData):
673 Helper method to create a Request instance for an update (view)
674 action.
676 Returns populated `Request` instance.
678 params = {
679 U_VIEW: U_UPDATE_VIEW,
682 data = {
683 U_ACTION: actionId,
684 U_ACTION_TOKEN: self._getActionToken(),
687 #data.update(extraData)
689 req = ClientCookie.Request(_buildURL(**params),
690 data = urllib.urlencode(data))
692 return req
695 # TODO: Extract additional common code from handling of labels?
696 def createLabel(self, labelName):
699 req = self._createUpdateRequest(U_CREATECATEGORY_ACTION + labelName)
701 # Note: Label name cache is updated by this call as well. (Handy!)
702 items = self._parsePage(req)
703 print items
704 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
707 def deleteLabel(self, labelName):
710 # TODO: Check labelName exits?
711 req = self._createUpdateRequest(U_DELETECATEGORY_ACTION + labelName)
713 # Note: Label name cache is updated by this call as well. (Handy!)
714 items = self._parsePage(req)
716 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
719 def renameLabel(self, oldLabelName, newLabelName):
722 # TODO: Check oldLabelName exits?
723 req = self._createUpdateRequest("%s%s^%s" % (U_RENAMECATEGORY_ACTION,
724 oldLabelName, newLabelName))
726 # Note: Label name cache is updated by this call as well. (Handy!)
727 items = self._parsePage(req)
729 return (items[D_ACTION_RESULT][0][AR_SUCCESS] == 1)
731 def storeFile(self, filename, label = None):
734 # TODO: Handle files larger than single attachment size.
735 # TODO: Allow file data objects to be supplied?
736 FILE_STORE_VERSION = "FSV_01"
737 FILE_STORE_SUBJECT_TEMPLATE = "%s %s" % (FILE_STORE_VERSION, "%s")
739 subject = FILE_STORE_SUBJECT_TEMPLATE % os.path.basename(filename)
741 msg = GmailComposedMessage(to="", subject=subject, body="",
742 filenames=[filename])
744 draftMsg = self.sendMessage(msg, asDraft = True)
746 if draftMsg and label:
747 draftMsg.addLabel(label)
749 return draftMsg
751 ## CONTACTS SUPPORT
752 def getContacts(self):
754 Returns a GmailContactList object
755 that has all the contacts in it as
756 GmailContacts
758 contactList = []
759 # pnl = a is necessary to get *all* contacts
760 myUrl = _buildURL(view='cl',search='contacts', pnl='a')
761 myData = self._parsePage(myUrl)
762 # This comes back with a dictionary
763 # with entry 'cl'
764 addresses = myData['cl']
765 for entry in addresses:
766 if len(entry) >= 6 and entry[0]=='ce':
767 newGmailContact = GmailContact(entry[1], entry[2], entry[4], entry[5])
768 #### new code used to get all the notes
769 #### not used yet due to lockdown problems
770 ##rawnotes = self._getSpecInfo(entry[1])
771 ##print rawnotes
772 ##newGmailContact = GmailContact(entry[1], entry[2], entry[4],rawnotes)
773 contactList.append(newGmailContact)
775 return GmailContactList(contactList)
777 def addContact(self, myContact, *extra_args):
779 Attempts to add a GmailContact to the gmail
780 address book. Returns true if successful,
781 false otherwise
783 Please note that after version 0.1.3.3,
784 addContact takes one argument of type
785 GmailContact, the contact to add.
787 The old signature of:
788 addContact(name, email, notes='') is still
789 supported, but deprecated.
791 if len(extra_args) > 0:
792 # The user has passed in extra arguments
793 # He/she is probably trying to invoke addContact
794 # using the old, deprecated signature of:
795 # addContact(self, name, email, notes='')
796 # Build a GmailContact object and use that instead
797 (name, email) = (myContact, extra_args[0])
798 if len(extra_args) > 1:
799 notes = extra_args[1]
800 else:
801 notes = ''
802 myContact = GmailContact(-1, name, email, notes)
804 # TODO: In the ideal world, we'd extract these specific
805 # constants into a nice constants file
807 # This mostly comes from the Johnvey Gmail API,
808 # but also from the gmail.py cited earlier
809 myURL = _buildURL(view='up')
811 myDataList = [ ('act','ec'),
812 ('at', self.getCookie(ACTION_TOKEN_COOKIE)),
813 ('ct_nm', myContact.getName()),
814 ('ct_em', myContact.getEmail()),
815 ('ct_id', -1 )
818 notes = myContact.getNotes()
819 if notes != '':
820 myDataList.append( ('ctf_n', notes) )
822 validinfokeys = [
823 'i', # IM
824 'p', # Phone
825 'd', # Company
826 'a', # ADR
827 'e', # Email
828 'm', # Mobile
829 'b', # Pager
830 'f', # Fax
831 't', # Title
832 'o', # Other
835 moreInfo = myContact.getMoreInfo()
836 ctsn_num = -1
837 if moreInfo != {}:
838 for ctsf,ctsf_data in moreInfo.items():
839 ctsn_num += 1
840 # data section header, WORK, HOME,...
841 sectionenum ='ctsn_%02d' % ctsn_num
842 myDataList.append( ( sectionenum, ctsf ))
843 ctsf_num = -1
845 if isinstance(ctsf_data[0],str):
846 ctsf_num += 1
847 # data section
848 subsectionenum = 'ctsf_%02d_%02d_%s' % (ctsn_num, ctsf_num, ctsf_data[0]) # ie. ctsf_00_01_p
849 myDataList.append( (subsectionenum, ctsf_data[1]) )
850 else:
851 for info in ctsf_data:
852 if validinfokeys.count(info[0]) > 0:
853 ctsf_num += 1
854 # data section
855 subsectionenum = 'ctsf_%02d_%02d_%s' % (ctsn_num, ctsf_num, info[0]) # ie. ctsf_00_01_p
856 myDataList.append( (subsectionenum, info[1]) )
858 myData = urllib.urlencode(myDataList)
859 request = ClientCookie.Request(myURL,
860 data = myData)
861 pageData = self._retrievePage(request)
863 if pageData.find("The contact was successfully added") == -1:
864 print pageData
865 if pageData.find("already has the email address") > 0:
866 raise Exception("Someone with same email already exists in Gmail.")
867 elif pageData.find("https://www.google.com/accounts/ServiceLogin"):
868 raise Exception("Login has expired.")
869 return False
870 else:
871 return True
873 def _removeContactById(self, id):
875 Attempts to remove the contact that occupies
876 id "id" from the gmail address book.
877 Returns True if successful,
878 False otherwise.
880 This is a little dangerous since you don't really
881 know who you're deleting. Really,
882 this should return the name or something of the
883 person we just killed.
885 Don't call this method.
886 You should be using removeContact instead.
888 myURL = _buildURL(search='contacts', ct_id = id, c=id, act='dc', at=self.getCookie(ACTION_TOKEN_COOKIE), view='up')
889 pageData = self._retrievePage(myURL)
891 if pageData.find("The contact has been deleted") == -1:
892 return False
893 else:
894 return True
896 def removeContact(self, gmailContact):
898 Attempts to remove the GmailContact passed in
899 Returns True if successful, False otherwise.
901 # Let's re-fetch the contact list to make
902 # sure we're really deleting the guy
903 # we think we're deleting
904 newContactList = self.getContacts()
905 newVersionOfPersonToDelete = newContactList.getContactById(gmailContact.getId())
906 # Ok, now we need to ensure that gmailContact
907 # is the same as newVersionOfPersonToDelete
908 # and then we can go ahead and delete him/her
909 if (gmailContact == newVersionOfPersonToDelete):
910 return self._removeContactById(gmailContact.getId())
911 else:
912 # We have a cache coherency problem -- someone
913 # else now occupies this ID slot.
914 # TODO: Perhaps signal this in some nice way
915 # to the end user?
917 print "Unable to delete."
918 print "Has someone else been modifying the contacts list while we have?"
919 print "Old version of person:",gmailContact
920 print "New version of person:",newVersionOfPersonToDelete
921 return False
923 ## Don't remove this. contact stas
924 ## def _getSpecInfo(self,id):
925 ## """
926 ## Return all the notes data.
927 ## This is currently not used due to the fact that it requests pages in
928 ## a dos attack manner.
929 ## """
930 ## myURL =_buildURL(search='contacts',ct_id=id,c=id,\
931 ## at=self._cookieJar._cookies['GMAIL_AT'],view='ct')
932 ## pageData = self._retrievePage(myURL)
933 ## myData = self._parsePage(myURL)
934 ## #print "\nmyData form _getSpecInfo\n",myData
935 ## rawnotes = myData['cov'][7]
936 ## return rawnotes
938 class GmailContact:
940 Class for storing a Gmail Contacts list entry
942 def __init__(self, name, email, *extra_args):
944 Returns a new GmailContact object
945 (you can then call addContact on this to commit
946 it to the Gmail addressbook, for example)
948 Consider calling setNotes() and setMoreInfo()
949 to add extended information to this contact
951 # Support populating other fields if we're trying
952 # to invoke this the old way, with the old constructor
953 # whose signature was __init__(self, id, name, email, notes='')
954 id = -1
955 notes = ''
957 if len(extra_args) > 0:
958 (id, name) = (name, email)
959 email = extra_args[0]
960 if len(extra_args) > 1:
961 notes = extra_args[1]
962 else:
963 notes = ''
965 self.id = id
966 self.name = name
967 self.email = email
968 self.notes = notes
969 self.moreInfo = {}
970 def __str__(self):
971 return "%s %s %s %s" % (self.id, self.name, self.email, self.notes)
972 def __eq__(self, other):
973 if not isinstance(other, GmailContact):
974 return False
975 return (self.getId() == other.getId()) and \
976 (self.getName() == other.getName()) and \
977 (self.getEmail() == other.getEmail()) and \
978 (self.getNotes() == other.getNotes())
979 def getId(self):
980 return self.id
981 def getName(self):
982 return self.name
983 def getEmail(self):
984 return self.email
985 def getNotes(self):
986 return self.notes
987 def setNotes(self, notes):
989 Sets the notes field for this GmailContact
990 Note that this does NOT change the note
991 field on Gmail's end; only adding or removing
992 contacts modifies them
994 self.notes = notes
996 def getMoreInfo(self):
997 return self.moreInfo
998 def setMoreInfo(self, moreInfo):
1000 moreInfo format
1001 ---------------
1002 Use special key values::
1003 'i' = IM
1004 'p' = Phone
1005 'd' = Company
1006 'a' = ADR
1007 'e' = Email
1008 'm' = Mobile
1009 'b' = Pager
1010 'f' = Fax
1011 't' = Title
1012 'o' = Other
1014 Simple example::
1016 moreInfo = {'Home': ( ('a','852 W Barry'),
1017 ('p', '1-773-244-1980'),
1018 ('i', 'aim:brianray34') ) }
1020 Complex example::
1022 moreInfo = {
1023 'Personal': (('e', 'Home Email'),
1024 ('f', 'Home Fax')),
1025 'Work': (('d', 'Sample Company'),
1026 ('t', 'Job Title'),
1027 ('o', 'Department: Department1'),
1028 ('o', 'Department: Department2'),
1029 ('p', 'Work Phone'),
1030 ('m', 'Mobile Phone'),
1031 ('f', 'Work Fax'),
1032 ('b', 'Pager')) }
1034 self.moreInfo = moreInfo
1035 def getVCard(self):
1036 """Returns a vCard 3.0 for this
1037 contact, as a string"""
1038 # The \r is is to comply with the RFC2425 section 5.8.1
1039 vcard = "BEGIN:VCARD\r\n"
1040 vcard += "VERSION:3.0\r\n"
1041 ## Deal with multiline notes
1042 ##vcard += "NOTE:%s\n" % self.getNotes().replace("\n","\\n")
1043 vcard += "NOTE:%s\r\n" % self.getNotes()
1044 # Fake-out N by splitting up whatever we get out of getName
1045 # This might not always do 'the right thing'
1046 # but it's a *reasonable* compromise
1047 fullname = self.getName().split()
1048 fullname.reverse()
1049 vcard += "N:%s" % ';'.join(fullname) + "\r\n"
1050 vcard += "FN:%s\r\n" % self.getName()
1051 vcard += "EMAIL;TYPE=INTERNET:%s\r\n" % self.getEmail()
1052 vcard += "END:VCARD\r\n\r\n"
1053 # Final newline in case we want to put more than one in a file
1054 return vcard
1056 class GmailContactList:
1058 Class for storing an entire Gmail contacts list
1059 and retrieving contacts by Id, Email address, and name
1061 def __init__(self, contactList):
1062 self.contactList = contactList
1063 def __str__(self):
1064 return '\n'.join([str(item) for item in self.contactList])
1065 def getCount(self):
1067 Returns number of contacts
1069 return len(self.contactList)
1070 def getAllContacts(self):
1072 Returns an array of all the
1073 GmailContacts
1075 return self.contactList
1076 def getContactByName(self, name):
1078 Gets the first contact in the
1079 address book whose name is 'name'.
1081 Returns False if no contact
1082 could be found
1084 nameList = self.getContactListByName(name)
1085 if len(nameList) > 0:
1086 return nameList[0]
1087 else:
1088 return False
1089 def getContactByEmail(self, email):
1091 Gets the first contact in the
1092 address book whose name is 'email'.
1093 As of this writing, Gmail insists
1094 upon a unique email; i.e. two contacts
1095 cannot share an email address.
1097 Returns False if no contact
1098 could be found
1100 emailList = self.getContactListByEmail(email)
1101 if len(emailList) > 0:
1102 return emailList[0]
1103 else:
1104 return False
1105 def getContactById(self, myId):
1107 Gets the first contact in the
1108 address book whose id is 'myId'.
1110 REMEMBER: ID IS A STRING
1112 Returns False if no contact
1113 could be found
1115 idList = self.getContactListById(myId)
1116 if len(idList) > 0:
1117 return idList[0]
1118 else:
1119 return False
1120 def getContactListByName(self, name):
1122 This function returns a LIST
1123 of GmailContacts whose name is
1124 'name'.
1126 Returns an empty list if no contacts
1127 were found
1129 nameList = []
1130 for entry in self.contactList:
1131 if entry.getName() == name:
1132 nameList.append(entry)
1133 return nameList
1134 def getContactListByEmail(self, email):
1136 This function returns a LIST
1137 of GmailContacts whose email is
1138 'email'. As of this writing, two contacts
1139 cannot share an email address, so this
1140 should only return just one item.
1141 But it doesn't hurt to be prepared?
1143 Returns an empty list if no contacts
1144 were found
1146 emailList = []
1147 for entry in self.contactList:
1148 if entry.getEmail() == email:
1149 emailList.append(entry)
1150 return emailList
1151 def getContactListById(self, myId):
1153 This function returns a LIST
1154 of GmailContacts whose id is
1155 'myId'. We expect there only to
1156 be one, but just in case!
1158 Remember: ID IS A STRING
1160 Returns an empty list if no contacts
1161 were found
1163 idList = []
1164 for entry in self.contactList:
1165 if entry.getId() == myId:
1166 idList.append(entry)
1167 return idList
1168 def search(self, searchTerm):
1170 This function returns a LIST
1171 of GmailContacts whose name or
1172 email address matches the 'searchTerm'.
1174 Returns an empty list if no matches
1175 were found.
1177 searchResults = []
1178 for entry in self.contactList:
1179 p = re.compile(searchTerm, re.IGNORECASE)
1180 if p.search(entry.getName()) or p.search(entry.getEmail()):
1181 searchResults.append(entry)
1182 return searchResults
1184 class GmailSearchResult:
1188 def __init__(self, account, search, threadsInfo):
1191 `threadsInfo` -- As returned from Gmail but unbunched.
1193 #print "\nthreadsInfo\n",threadsInfo
1194 try:
1195 if not type(threadsInfo[0]) is types.ListType:
1196 threadsInfo = [threadsInfo]
1197 except IndexError:
1198 # print "No messages found"
1199 pass
1201 self._account = account
1202 self.search = search # TODO: Turn into object + format nicely.
1203 self._threads = []
1205 for thread in threadsInfo:
1206 self._threads.append(GmailThread(self, thread[0]))
1209 def __iter__(self):
1212 return iter(self._threads)
1214 def __len__(self):
1217 return len(self._threads)
1219 def __getitem__(self,key):
1222 return self._threads.__getitem__(key)
1225 class GmailSessionState:
1229 def __init__(self, account = None, filename = ""):
1232 if account:
1233 self.state = (account.name, account._cookieJar)
1234 elif filename:
1235 self.state = load(open(filename, "rb"))
1236 else:
1237 raise ValueError("GmailSessionState must be instantiated with " \
1238 "either GmailAccount object or filename.")
1241 def save(self, filename):
1244 dump(self.state, open(filename, "wb"), -1)
1247 class _LabelHandlerMixin(object):
1250 Note: Because a message id can be used as a thread id this works for
1251 messages as well as threads.
1253 def __init__(self):
1254 self._labels = None
1256 def _makeLabelList(self, labelList):
1257 self._labels = labelList
1259 def addLabel(self, labelName):
1262 # Note: It appears this also automatically creates new labels.
1263 result = self._account._doThreadAction(U_ADDCATEGORY_ACTION+labelName,
1264 self)
1265 if not self._labels:
1266 self._makeLabelList([])
1267 # TODO: Caching this seems a little dangerous; suppress duplicates maybe?
1268 self._labels.append(labelName)
1269 return result
1272 def removeLabel(self, labelName):
1275 # TODO: Check label is already attached?
1276 # Note: An error is not generated if the label is not already attached.
1277 result = \
1278 self._account._doThreadAction(U_REMOVECATEGORY_ACTION+labelName,
1279 self)
1281 removeLabel = True
1282 try:
1283 self._labels.remove(labelName)
1284 except:
1285 removeLabel = False
1286 pass
1288 # If we don't check both, we might end up in some weird inconsistent state
1289 return result and removeLabel
1291 def getLabels(self):
1292 return self._labels
1296 class GmailThread(_LabelHandlerMixin):
1298 Note: As far as I can tell, the "canonical" thread id is always the same
1299 as the id of the last message in the thread. But it appears that
1300 the id of any message in the thread can be used to retrieve
1301 the thread information.
1305 def __init__(self, parent, threadsInfo):
1308 _LabelHandlerMixin.__init__(self)
1310 # TODO Handle this better?
1311 self._parent = parent
1312 self._account = self._parent._account
1314 self.id = threadsInfo[T_THREADID] # TODO: Change when canonical updated?
1315 self.subject = threadsInfo[T_SUBJECT_HTML]
1317 self.snippet = threadsInfo[T_SNIPPET_HTML]
1318 #self.extraSummary = threadInfo[T_EXTRA_SNIPPET] #TODO: What is this?
1320 # TODO: Store other info?
1321 # Extract number of messages in thread/conversation.
1323 self._authors = threadsInfo[T_AUTHORS_HTML]
1324 self.info = threadsInfo
1326 try:
1327 # TODO: Find out if this information can be found another way...
1328 # (Without another page request.)
1329 self._length = int(re.search("\((\d+?)\)\Z",
1330 self._authors).group(1))
1331 except AttributeError,info:
1332 # If there's no message count then the thread only has one message.
1333 self._length = 1
1335 # TODO: Store information known about the last message (e.g. id)?
1336 self._messages = []
1338 # Populate labels
1339 self._makeLabelList(threadsInfo[T_CATEGORIES])
1341 def __getattr__(self, name):
1343 Dynamically dispatch some interesting thread properties.
1345 attrs = { 'unread': T_UNREAD,
1346 'star': T_STAR,
1347 'date': T_DATE_HTML,
1348 'authors': T_AUTHORS_HTML,
1349 'flags': T_FLAGS,
1350 'subject': T_SUBJECT_HTML,
1351 'snippet': T_SNIPPET_HTML,
1352 'categories': T_CATEGORIES,
1353 'attach': T_ATTACH_HTML,
1354 'matching_msgid': T_MATCHING_MSGID,
1355 'extra_snippet': T_EXTRA_SNIPPET }
1356 if name in attrs:
1357 return self.info[ attrs[name] ];
1359 raise AttributeError("no attribute %s" % name)
1361 def __len__(self):
1364 return self._length
1367 def __iter__(self):
1370 if not self._messages:
1371 self._messages = self._getMessages(self)
1373 return iter(self._messages)
1375 def __getitem__(self, key):
1378 if not self._messages:
1379 self._messages = self._getMessages(self)
1380 try:
1381 result = self._messages.__getitem__(key)
1382 except IndexError:
1383 result = []
1384 return result
1386 def _getMessages(self, thread):
1389 # TODO: Do this better.
1390 # TODO: Specify the query folder using our specific search?
1391 items = self._account._parseSearchResult(U_QUERY_SEARCH,
1392 view = U_CONVERSATION_VIEW,
1393 th = thread.id,
1394 q = "in:anywhere")
1395 result = []
1396 # TODO: Handle this better?
1397 # Note: This handles both draft & non-draft messages in a thread...
1398 for key, isDraft in [(D_MSGINFO, False), (D_DRAFTINFO, True)]:
1399 try:
1400 msgsInfo = items[key]
1401 except KeyError:
1402 # No messages of this type (e.g. draft or non-draft)
1403 continue
1404 else:
1405 # TODO: Handle special case of only 1 message in thread better?
1406 if type(msgsInfo[0]) != types.ListType:
1407 msgsInfo = [msgsInfo]
1408 for msg in msgsInfo:
1409 result += [GmailMessage(thread, msg, isDraft = isDraft)]
1412 return result
1414 class GmailMessageStub(_LabelHandlerMixin):
1417 Intended to be used where not all message information is known/required.
1419 NOTE: This may go away.
1422 # TODO: Provide way to convert this to a full `GmailMessage` instance
1423 # or allow `GmailMessage` to be created without all info?
1425 def __init__(self, id = None, _account = None):
1428 _LabelHandlerMixin.__init__(self)
1429 self.id = id
1430 self._account = _account
1434 class GmailMessage(object):
1438 def __init__(self, parent, msgData, isDraft = False):
1441 Note: `msgData` can be from either D_MSGINFO or D_DRAFTINFO.
1443 # TODO: Automatically detect if it's a draft or not?
1444 # TODO Handle this better?
1445 self._parent = parent
1446 self._account = self._parent._account
1448 self.author = msgData[MI_AUTHORFIRSTNAME]
1449 self.id = msgData[MI_MSGID]
1450 self.number = msgData[MI_NUM]
1451 self.subject = msgData[MI_SUBJECT]
1452 self.to = msgData[MI_TO]
1453 self.cc = msgData[MI_CC]
1454 self.bcc = msgData[MI_BCC]
1455 self.sender = msgData[MI_AUTHOREMAIL]
1457 self.attachments = [GmailAttachment(self, attachmentInfo)
1458 for attachmentInfo in msgData[MI_ATTACHINFO]]
1460 # TODO: Populate additional fields & cache...(?)
1462 # TODO: Handle body differently if it's from a draft?
1463 self.isDraft = isDraft
1465 self._source = None
1468 def _getSource(self):
1471 if not self._source:
1472 # TODO: Do this more nicely...?
1473 # TODO: Strip initial white space & fix up last line ending
1474 # to make it legal as per RFC?
1475 self._source = self._account.getRawMessage(self.id)
1477 return self._source
1479 source = property(_getSource, doc = "")
1483 class GmailAttachment:
1487 def __init__(self, parent, attachmentInfo):
1490 # TODO Handle this better?
1491 self._parent = parent
1492 self._account = self._parent._account
1494 self.id = attachmentInfo[A_ID]
1495 self.filename = attachmentInfo[A_FILENAME]
1496 self.mimetype = attachmentInfo[A_MIMETYPE]
1497 self.filesize = attachmentInfo[A_FILESIZE]
1499 self._content = None
1502 def _getContent(self):
1505 if not self._content:
1506 # TODO: Do this a more nicely...?
1507 self._content = self._account._retrievePage(
1508 _buildURL(view=U_ATTACHMENT_VIEW, disp="attd",
1509 attid=self.id, th=self._parent._parent.id), 1)
1511 return self._content
1513 content = property(_getContent, doc = "")
1516 def _getFullId(self):
1519 Returns the "full path"/"full id" of the attachment. (Used
1520 to refer to the file when forwarding.)
1522 The id is of the form: "<thread_id>_<msg_id>_<attachment_id>"
1525 return "%s_%s_%s" % (self._parent._parent.id,
1526 self._parent.id,
1527 self.id)
1529 _fullId = property(_getFullId, doc = "")
1533 class GmailComposedMessage:
1537 def __init__(self, to, subject, body, cc = None, bcc = None,
1538 filenames = None, files = None):
1541 `filenames` - list of the file paths of the files to attach.
1542 `files` - list of objects implementing sub-set of
1543 `email.Message.Message` interface (`get_filename`,
1544 `get_content_type`, `get_payload`). This is to
1545 allow use of payloads from Message instances.
1546 TODO: Change this to be simpler class we define ourselves?
1548 self.to = to
1549 self.subject = subject
1550 self.body = body
1551 self.cc = cc
1552 self.bcc = bcc
1553 self.filenames = filenames
1554 self.files = files
1558 if __name__ == "__main__":
1559 import sys
1560 from getpass import getpass
1562 try:
1563 name = sys.argv[1]
1564 except IndexError:
1565 name = raw_input("Gmail account name: ")
1567 pw = getpass("Password: ")
1568 domain = raw_input("Domain? [leave blank for Gmail]: ")
1570 ga = GmailAccount(name, pw, domain=domain)
1572 print "\nPlease wait, logging in..."
1574 try:
1575 ga.login()
1576 except GmailLoginFailure,e:
1577 print "\nLogin failed. (%s)" % e.message
1578 else:
1579 print "Login successful.\n"
1581 # TODO: Use properties instead?
1582 quotaInfo = ga.getQuotaInfo()
1583 quotaMbUsed = quotaInfo[QU_SPACEUSED]
1584 quotaMbTotal = quotaInfo[QU_QUOTA]
1585 quotaPercent = quotaInfo[QU_PERCENT]
1586 print "%s of %s used. (%s)\n" % (quotaMbUsed, quotaMbTotal, quotaPercent)
1588 searches = STANDARD_FOLDERS + ga.getLabelNames()
1589 name = None
1590 while 1:
1591 try:
1592 print "Select folder or label to list: (Ctrl-C to exit)"
1593 for optionId, optionName in enumerate(searches):
1594 print " %d. %s" % (optionId, optionName)
1595 while not name:
1596 try:
1597 name = searches[int(raw_input("Choice: "))]
1598 except ValueError,info:
1599 print info
1600 name = None
1601 if name in STANDARD_FOLDERS:
1602 result = ga.getMessagesByFolder(name, True)
1603 else:
1604 result = ga.getMessagesByLabel(name, True)
1606 if not len(result):
1607 print "No threads found in `%s`." % name
1608 break
1609 name = None
1610 tot = len(result)
1612 i = 0
1613 for thread in result:
1614 print "%s messages in thread" % len(thread)
1615 print thread.id, len(thread), thread.subject
1616 for msg in thread:
1617 print "\n ", msg.id, msg.number, msg.author,msg.subject
1618 # Just as an example of other usefull things
1619 #print " ", msg.cc, msg.bcc,msg.sender
1620 i += 1
1621 print
1622 print "number of threads:",tot
1623 print "number of messages:",i
1624 except KeyboardInterrupt:
1625 break
1627 print "\n\nDone."