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)
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
, 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
)
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 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.
371 # TODO: Expire cached values?
372 # TODO: Do this better.
374 self
._cachedQuotaInfo
= items
[D_QUOTA
]
377 #pprint.pprint(items)
380 self
._cachedLabelNames
= [category
[CT_NAME
] for category
in items
[D_CATEGORIES
][0]]
387 def _parseSearchResult(self
, searchType
, start
= 0, **kwargs
):
390 params
= {U_SEARCH
: searchType
,
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?
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?
413 threads
= items
[D_THREAD
]
418 if not type(th
[0]) is types
.ListType
:
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
,
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
,
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
)
516 result
= items
[D_THREADLIST_SUMMARY
][0][TS_TOTAL_MSGS
]
522 def _getActionToken(self
):
526 at
= self
.getCookie(ACTION_TOKEN_COOKIE
)
528 self
.getLabelNames(True)
529 at
= self
.getCookie(ACTION_TOKEN_COOKIE
)
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
: "",
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
,
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!
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
)
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
614 resultInfo
= items
[D_SENDMAIL_RESULT
][0]
616 if resultInfo
[SM_SUCCESS
]:
617 result
= GmailMessageStub(id = resultInfo
[SM_NEWTHREADID
],
620 raise GmailSendError
, resultInfo
[SM_MSG
]
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?
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?
647 U_SEARCH
: U_ALL_SEARCH
, #TODO:Check this search value always works.
648 U_VIEW
: U_UPDATE_VIEW
,
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?
671 def _createUpdateRequest(self
, actionId
): #extraData):
673 Helper method to create a Request instance for an update (view)
676 Returns populated `Request` instance.
679 U_VIEW
: U_UPDATE_VIEW
,
684 U_ACTION_TOKEN
: self
._getActionToken
(),
687 #data.update(extraData)
689 req
= ClientCookie
.Request(_buildURL(**params
),
690 data
= urllib
.urlencode(data
))
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
)
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
)
752 def getContacts(self
):
754 Returns a GmailContactList object
755 that has all the contacts in it as
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
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])
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,
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]
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()),
818 notes
= myContact
.getNotes()
820 myDataList
.append( ('ctf_n', notes
) )
835 moreInfo
= myContact
.getMoreInfo()
838 for ctsf
,ctsf_data
in moreInfo
.items():
840 # data section header, WORK, HOME,...
841 sectionenum
='ctsn_%02d' % ctsn_num
842 myDataList
.append( ( sectionenum
, ctsf
))
845 if isinstance(ctsf_data
[0],str):
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]) )
851 for info
in ctsf_data
:
852 if validinfokeys
.count(info
[0]) > 0:
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
,
861 pageData
= self
._retrievePage
(request
)
863 if pageData
.find("The contact was successfully added") == -1:
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.")
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,
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:
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())
912 # We have a cache coherency problem -- someone
913 # else now occupies this ID slot.
914 # TODO: Perhaps signal this in some nice way
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
923 ## Don't remove this. contact stas
924 ## def _getSpecInfo(self,id):
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.
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]
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='')
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]
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
):
975 return (self
.getId() == other
.getId()) and \
976 (self
.getName() == other
.getName()) and \
977 (self
.getEmail() == other
.getEmail()) and \
978 (self
.getNotes() == other
.getNotes())
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
996 def getMoreInfo(self
):
998 def setMoreInfo(self
, moreInfo
):
1002 Use special key values::
1016 moreInfo = {'Home': ( ('a','852 W Barry'),
1017 ('p', '1-773-244-1980'),
1018 ('i', 'aim:brianray34') ) }
1023 'Personal': (('e', 'Home Email'),
1025 'Work': (('d', 'Sample Company'),
1027 ('o', 'Department: Department1'),
1028 ('o', 'Department: Department2'),
1029 ('p', 'Work Phone'),
1030 ('m', 'Mobile Phone'),
1034 self
.moreInfo
= moreInfo
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()
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
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
1064 return '\n'.join([str(item
) for item
in self
.contactList
])
1067 Returns number of contacts
1069 return len(self
.contactList
)
1070 def getAllContacts(self
):
1072 Returns an array of all the
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
1084 nameList
= self
.getContactListByName(name
)
1085 if len(nameList
) > 0:
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
1100 emailList
= self
.getContactListByEmail(email
)
1101 if len(emailList
) > 0:
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
1115 idList
= self
.getContactListById(myId
)
1120 def getContactListByName(self
, name
):
1122 This function returns a LIST
1123 of GmailContacts whose name is
1126 Returns an empty list if no contacts
1130 for entry
in self
.contactList
:
1131 if entry
.getName() == name
:
1132 nameList
.append(entry
)
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
1147 for entry
in self
.contactList
:
1148 if entry
.getEmail() == email
:
1149 emailList
.append(entry
)
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
1164 for entry
in self
.contactList
:
1165 if entry
.getId() == myId
:
1166 idList
.append(entry
)
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
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
1195 if not type(threadsInfo
[0]) is types
.ListType
:
1196 threadsInfo
= [threadsInfo
]
1198 # print "No messages found"
1201 self
._account
= account
1202 self
.search
= search
# TODO: Turn into object + format nicely.
1205 for thread
in threadsInfo
:
1206 self
._threads
.append(GmailThread(self
, thread
[0]))
1212 return iter(self
._threads
)
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
= ""):
1233 self
.state
= (account
.name
, account
._cookieJar
)
1235 self
.state
= load(open(filename
, "rb"))
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.
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
,
1265 if not self
._labels
:
1266 self
._makeLabelList
([])
1267 # TODO: Caching this seems a little dangerous; suppress duplicates maybe?
1268 self
._labels
.append(labelName
)
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.
1278 self
._account
._doThreadAction
(U_REMOVECATEGORY_ACTION
+labelName
,
1283 self
._labels
.remove(labelName
)
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
):
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
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.
1335 # TODO: Store information known about the last message (e.g. id)?
1339 self
._makeLabelList
(threadsInfo
[T_CATEGORIES
])
1341 def __getattr__(self
, name
):
1343 Dynamically dispatch some interesting thread properties.
1345 attrs
= { 'unread': T_UNREAD
,
1347 'date': T_DATE_HTML
,
1348 'authors': T_AUTHORS_HTML
,
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
}
1357 return self
.info
[ attrs
[name
] ];
1359 raise AttributeError("no attribute %s" % name
)
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
)
1381 result
= self
._messages
.__getitem
__(key
)
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
,
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)]:
1400 msgsInfo
= items
[key
]
1402 # No messages of this type (e.g. draft or non-draft)
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
)]
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
)
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
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)
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,
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?
1549 self
.subject
= subject
1553 self
.filenames
= filenames
1558 if __name__
== "__main__":
1560 from getpass
import getpass
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..."
1576 except GmailLoginFailure
,e
:
1577 print "\nLogin failed. (%s)" % e
.message
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()
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
)
1597 name
= searches
[int(raw_input("Choice: "))]
1598 except ValueError,info
:
1601 if name
in STANDARD_FOLDERS
:
1602 result
= ga
.getMessagesByFolder(name
, True)
1604 result
= ga
.getMessagesByLabel(name
, True)
1607 print "No threads found in `%s`." % name
1613 for thread
in result
:
1614 print "%s messages in thread" % len(thread
)
1615 print thread
.id, len(thread
), thread
.subject
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
1622 print "number of threads:",tot
1623 print "number of messages:",i
1624 except KeyboardInterrupt: