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)
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.
28 from lgconstants
import *
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"
59 # NOTE: All other DI_* field offsets seem to match the MI_* field offsets
62 versionWarned
= False # If the Javascript version is different have we
66 RE_SPLIT_PAGE_CONTENT
= re
.compile("D\((.*?)\);", re
.DOTALL
)
68 class GmailError(Exception):
70 Exception thrown upon gmail-specific failures, in particular a
71 failure to log in and a failure to parse responses.
76 class GmailSendError(Exception):
78 Exception to throw if we are unable to send a message
82 def _parsePage(pageContent
):
84 Parse the supplied HTML page and extract useful information from
85 the embedded Javascript.
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
)
96 exec data
in {'__builtins__': None}, {'D': lambda x
: result
.append(x
)}
97 except SyntaxError,info
:
99 raise GmailError
, 'Failed to parse data returned from gmail.'
107 parsedValue
= item
[1:]
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
)
120 itemsDict
[name
].append(parsedValue
)
122 if len(parsedValue
) and type(parsedValue
[0]) is types
.ListType
:
124 for item
in parsedValue
:
125 itemsDict
[name
].append(item
)
127 itemsDict
[name
] = [parsedValue
]
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.
137 # TODO: Decide if this is the best approach.
138 for group
in infoItems
:
139 if type(group
) == tuple:
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'))
156 req
.add_header("Host", new_host
.groups()[0])
157 result
= ClientCookie
.HTTPRedirectHandler
.http_error_302(
158 self
, req
, fp
, code
, msg
, headers
)
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']:
182 mimeMsg
.attach(mimeItem
)
184 if filenames
or files
:
185 filenames
= filenames
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...
192 contentType
= mimetypes
.guess_type(filename
)[0]
193 payload
= open(filename
, "rb").read()
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)
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']:
214 mimeMsg
.attach(mimeItem
)
216 del mimeMsg
['MIME-Version']
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:
228 except GmailLoginFailure,e:
230 print e# uses the __str__
232 def __init__(self
,message
):
233 self
.message
= message
235 return repr(self
.message
)
241 def __init__(self
, name
= "", pw
= "", state
= None, domain
= None):
242 global URL_LOGIN
, URL_GMAIL
247 URL_LOGIN
= "https://www.google.com/a/" + self
.domain
+ "/LoginAction2"
248 URL_GMAIL
= "http://mail.google.com/a/" + self
.domain
+ "/?ui=1&"
251 URL_LOGIN
= GMAIL_URL_LOGIN
252 URL_GMAIL
= GMAIL_URL_GMAIL
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
))
268 self
.opener
= ClientCookie
.build_opener(
269 ClientCookie
.HTTPHandler(),
270 ClientCookie
.HTTPSHandler(),
271 SmartRedirectHandler(self
._cookieJar
))
273 # TODO: Check for stale state cookies?
274 self
.name
, self
._cookieJar
= state
.state
276 raise ValueError("GmailAccount must be instantiated with " \
277 "either GmailSessionState object or name " \
280 self
._cachedQuotaInfo
= None
281 self
._cachedLabelNames
= None
287 # TODO: Throw exception if we were instantiated with state?
289 data
= urllib
.urlencode({'continue': URL_GMAIL
,
296 data
= urllib
.urlencode({'continue': URL_GMAIL
,
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
)
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...?
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
:
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
)
344 req
.add_header('User-Agent',
345 'Mozilla/5.0 (Compatible; libgmail-python)')
348 resp
= self
.opener
.open(req
)
349 except urllib2
.HTTPError
,info
:
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?
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.
368 # TODO: Expire cached values?
369 # TODO: Do this better.
371 self
._cachedQuotaInfo
= items
[D_QUOTA
]
374 #pprint.pprint(items)
377 self
._cachedLabelNames
= [category
[CT_NAME
] for category
in items
[D_CATEGORIES
][0]]
384 def _parseSearchResult(self
, searchType
, start
= 0, **kwargs
):
387 params
= {U_SEARCH
: searchType
,
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?
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?
410 threads
= items
[D_THREAD
]
415 if not type(th
[0]) is types
.ListType
:
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
,
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
,
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
)
513 result
= items
[D_THREADLIST_SUMMARY
][0][TS_TOTAL_MSGS
]
519 def _getActionToken(self
):
523 at
= self
.getCookie(ACTION_TOKEN_COOKIE
)
525 self
.getLabelNames(True)
526 at
= self
.getCookie(ACTION_TOKEN_COOKIE
)
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
: "",
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
,
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!
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
)
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
611 resultInfo
= items
[D_SENDMAIL_RESULT
][0]
613 if resultInfo
[SM_SUCCESS
]:
614 result
= GmailMessageStub(id = resultInfo
[SM_NEWTHREADID
],
617 raise GmailSendError
, resultInfo
[SM_MSG
]
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?
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?
644 U_SEARCH
: U_ALL_SEARCH
, #TODO:Check this search value always works.
645 U_VIEW
: U_UPDATE_VIEW
,
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?
668 def _createUpdateRequest(self
, actionId
): #extraData):
670 Helper method to create a Request instance for an update (view)
673 Returns populated `Request` instance.
676 U_VIEW
: U_UPDATE_VIEW
,
681 U_ACTION_TOKEN
: self
._getActionToken
(),
684 #data.update(extraData)
686 req
= ClientCookie
.Request(_buildURL(**params
),
687 data
= urllib
.urlencode(data
))
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
)
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
)
749 def getContacts(self
):
751 Returns a GmailContactList object
752 that has all the contacts in it as
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
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])
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,
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]
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()),
815 notes
= myContact
.getNotes()
817 myDataList
.append( ('ctf_n', notes
) )
832 moreInfo
= myContact
.getMoreInfo()
835 for ctsf
,ctsf_data
in moreInfo
.items():
837 # data section header, WORK, HOME,...
838 sectionenum
='ctsn_%02d' % ctsn_num
839 myDataList
.append( ( sectionenum
, ctsf
))
842 if isinstance(ctsf_data
[0],str):
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]) )
848 for info
in ctsf_data
:
849 if validinfokeys
.count(info
[0]) > 0:
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
,
858 pageData
= self
._retrievePage
(request
)
860 if pageData
.find("The contact was successfully added") == -1:
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.")
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,
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:
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())
909 # We have a cache coherency problem -- someone
910 # else now occupies this ID slot.
911 # TODO: Perhaps signal this in some nice way
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
920 ## Don't remove this. contact stas
921 ## def _getSpecInfo(self,id):
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.
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]
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='')
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]
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
):
972 return (self
.getId() == other
.getId()) and \
973 (self
.getName() == other
.getName()) and \
974 (self
.getEmail() == other
.getEmail()) and \
975 (self
.getNotes() == other
.getNotes())
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
993 def getMoreInfo(self
):
995 def setMoreInfo(self
, moreInfo
):
999 Use special key values::
1013 moreInfo = {'Home': ( ('a','852 W Barry'),
1014 ('p', '1-773-244-1980'),
1015 ('i', 'aim:brianray34') ) }
1020 'Personal': (('e', 'Home Email'),
1022 'Work': (('d', 'Sample Company'),
1024 ('o', 'Department: Department1'),
1025 ('o', 'Department: Department2'),
1026 ('p', 'Work Phone'),
1027 ('m', 'Mobile Phone'),
1031 self
.moreInfo
= moreInfo
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()
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
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
1061 return '\n'.join([str(item
) for item
in self
.contactList
])
1064 Returns number of contacts
1066 return len(self
.contactList
)
1067 def getAllContacts(self
):
1069 Returns an array of all the
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
1081 nameList
= self
.getContactListByName(name
)
1082 if len(nameList
) > 0:
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
1097 emailList
= self
.getContactListByEmail(email
)
1098 if len(emailList
) > 0:
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
1112 idList
= self
.getContactListById(myId
)
1117 def getContactListByName(self
, name
):
1119 This function returns a LIST
1120 of GmailContacts whose name is
1123 Returns an empty list if no contacts
1127 for entry
in self
.contactList
:
1128 if entry
.getName() == name
:
1129 nameList
.append(entry
)
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
1144 for entry
in self
.contactList
:
1145 if entry
.getEmail() == email
:
1146 emailList
.append(entry
)
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
1161 for entry
in self
.contactList
:
1162 if entry
.getId() == myId
:
1163 idList
.append(entry
)
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
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
1192 if not type(threadsInfo
[0]) is types
.ListType
:
1193 threadsInfo
= [threadsInfo
]
1195 # print "No messages found"
1198 self
._account
= account
1199 self
.search
= search
# TODO: Turn into object + format nicely.
1202 for thread
in threadsInfo
:
1203 self
._threads
.append(GmailThread(self
, thread
[0]))
1209 return iter(self
._threads
)
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
= ""):
1230 self
.state
= (account
.name
, account
._cookieJar
)
1232 self
.state
= load(open(filename
, "rb"))
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.
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
,
1262 if not self
._labels
:
1263 self
._makeLabelList
([])
1264 # TODO: Caching this seems a little dangerous; suppress duplicates maybe?
1265 self
._labels
.append(labelName
)
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.
1275 self
._account
._doThreadAction
(U_REMOVECATEGORY_ACTION
+labelName
,
1280 self
._labels
.remove(labelName
)
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
):
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
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.
1332 # TODO: Store information known about the last message (e.g. id)?
1336 self
._makeLabelList
(threadsInfo
[T_CATEGORIES
])
1338 def __getattr__(self
, name
):
1340 Dynamically dispatch some interesting thread properties.
1342 attrs
= { 'unread': T_UNREAD
,
1344 'date': T_DATE_HTML
,
1345 'authors': T_AUTHORS_HTML
,
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
}
1354 return self
.info
[ attrs
[name
] ];
1356 raise AttributeError("no attribute %s" % name
)
1367 if not self
._messages
:
1368 self
._messages
= self
._getMessages
(self
)
1370 yit
= self
._messages
1371 self
._messages
= None
1374 def __getitem__(self
, key
):
1377 if not self
._messages
:
1378 self
._messages
= self
._getMessages
(self
)
1380 result
= self
._messages
.__getitem
__(key
)
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
,
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)]:
1399 msgsInfo
= items
[key
]
1401 # No messages of this type (e.g. draft or non-draft)
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
)]
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
)
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
1460 self
.attachments
= [GmailAttachment(self
, attachmentInfo
)
1461 for attachmentInfo
in msgData
[MI_ATTACHINFO
]]
1463 self
.attachments
= []
1465 # TODO: Populate additional fields & cache...(?)
1467 # TODO: Handle body differently if it's from a draft?
1468 self
.isDraft
= isDraft
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,
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?
1550 self
.subject
= subject
1554 self
.filenames
= filenames
1559 if __name__
== "__main__":
1561 from getpass
import getpass
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..."
1577 except GmailLoginFailure
,e
:
1578 print "\nLogin failed. (%s)" % e
.message
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()
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
)
1598 name
= searches
[int(raw_input("Choice: "))]
1599 except ValueError,info
:
1602 if name
in STANDARD_FOLDERS
:
1603 result
= ga
.getMessagesByFolder(name
, True)
1605 result
= ga
.getMessagesByLabel(name
, True)
1608 print "No threads found in `%s`." % name
1614 for thread
in result
:
1615 print "%s messages in thread" % len(thread
)
1616 print thread
.id, len(thread
), thread
.subject
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
1623 print "number of threads:",tot
1624 print "number of messages:",i
1625 except KeyboardInterrupt: