from __future__ import generators import sys, os, re import locale try: True, False except NameError: # Maintain compatibility with Python 2.2 True, False = 1, 0 # MAPI imports etc. from win32com.client import Dispatch, constants from win32com.mapi import mapi, mapiutil from win32com.mapi.mapitags import * import pythoncom import winerror try: PR_USERFIELDS # only in new win32all except NameError: PR_USERFIELDS = 0x36E30102 # PROP_TAG(PT_BINARY, 0x36e3) # Additional MAPI constants we dont have in Python MESSAGE_MOVE = 0x1 # from MAPIdefs.h MSGFLAG_READ = 0x1 # from MAPIdefs.h MSGFLAG_UNSENT = 0x00000008 MYPR_BODY_HTML_A = 0x1013001e # magic MYPR_BODY_HTML_W = 0x1013001f # ditto CLEAR_READ_FLAG = 0x00000004 CLEAR_RN_PENDING = 0x00000020 CLEAR_NRN_PENDING = 0x00000040 SUPPRESS_RECEIPT = 0x1 USE_DEFERRED_ERRORS = mapi.MAPI_DEFERRED_ERRORS # or set to zero to see what changes #import warnings #if sys.version_info >= (2, 3): # # sick off the new hex() warnings! # warnings.filterwarnings("ignore", category=FutureWarning, append=1) # Nod to our automated test suite. Currently supports a hack so our test # message is filtered, and also for raising exceptions at key times. # see tester.py for more details. test_suite_running = False test_suite_failure_request = None test_suite_failure = None # Set to the number of times we should fail, or None for all times. test_suite_failure_count = None # Sometimes the test suite will request that we simulate MAPI errors. def help_test_suite(checkpoint_name): global test_suite_failure_request, test_suite_failure_count if test_suite_running and \ test_suite_failure_request == checkpoint_name: if test_suite_failure_count: test_suite_failure_count -= 1 if test_suite_failure_count==0: test_suite_failure_request = None raise test_suite_failure[0], test_suite_failure[1] # Exceptions raised by this module. Raw MAPI exceptions should never # be raised to the caller. class MsgStoreException(Exception): def __init__(self, mapi_exception, extra_msg = None): self.mapi_exception = mapi_exception self.extra_msg = extra_msg Exception.__init__(self, mapi_exception, extra_msg) def __str__(self): try: if self.mapi_exception is not None: err_str = GetCOMExceptionString(self.mapi_exception) else: err_str = self.extra_msg or '' return "%s: %s" % (self.__class__.__name__, err_str) # Python silently consumes exceptions here, and uses # except: print "FAILED to str() a MsgStore exception!" import traceback traceback.print_exc() # Exception raised when you attempt to get a message or folder that doesn't # exist. Usually means you are querying an ID that *was* valid, but has # since been moved or deleted. # Note you may get this exception "getting" objects (such as messages or # folders), or accessing properties once the object was created (the message # may be moved under us at any time) class NotFoundException(MsgStoreException): pass # Exception raised when you try and modify a "read only" object. # Only currently examples are Hotmail and IMAP folders. class ReadOnlyException(MsgStoreException): pass # The object has changed since it was opened. class ObjectChangedException(MsgStoreException): pass # Utility functions for exceptions. Convert a COM exception to the best # manager exception. def MsgStoreExceptionFromCOMException(com_exc): if IsNotFoundCOMException(com_exc): return NotFoundException(com_exc) if IsReadOnlyCOMException(com_exc): return ReadOnlyException(com_exc) scode = NormalizeCOMException(com_exc)[0] # And simple scode based ones. if scode == mapi.MAPI_E_OBJECT_CHANGED: return ObjectChangedException(com_exc) return MsgStoreException(com_exc) def NormalizeCOMException(exc_val): hr, msg, exc, arg_err = exc_val if hr == winerror.DISP_E_EXCEPTION and exc: # 'client' exception - unpack 'exception object' wcode, source, msg, help1, help2, hr = exc return hr, msg, exc, arg_err # Build a reasonable string from a COM exception tuple def GetCOMExceptionString(exc_val): hr, msg, exc, arg_err = NormalizeCOMException(exc_val) err_string = mapiutil.GetScodeString(hr) return "Exception 0x%x (%s): %s" % (hr, err_string, msg) # Does this exception probably mean "object not found"? def IsNotFoundCOMException(exc_val): hr, msg, exc, arg_err = NormalizeCOMException(exc_val) return hr in [mapi.MAPI_E_OBJECT_DELETED, mapi.MAPI_E_NOT_FOUND] # Does this exception probably mean "object not available 'cos you ain't logged # in, or 'cos the server is down"? def IsNotAvailableCOMException(exc_val): hr, msg, exc, arg_err = NormalizeCOMException(exc_val) return hr == mapi.MAPI_E_FAILONEPROVIDER def IsReadOnlyCOMException(exc_val): # This seems to happen for IMAP mails (0x800cccd3) # and also for hotmail messages (0x8004dff7) known_failure_codes = -2146644781, -2147164169 exc_val = NormalizeCOMException(exc_val) return exc_val[0] in known_failure_codes def ReportMAPIError(manager, what, exc_val): hr, exc_msg, exc, arg_err = exc_val if hr == mapi.MAPI_E_TABLE_TOO_BIG: err_msg = what + " failed as one of your\r\n" \ "Outlook folders is full. Futher operations are\r\n" \ "likely to fail until you clean up this folder.\r\n\r\n" \ "This message will not be reported again until SpamBayes\r\n"\ "is restarted." else: err_msg = what + " failed due to an unexpected Outlook error.\r\n" \ + GetCOMExceptionString(exc_val) + "\r\n\r\n" \ "It is recommended you restart Outlook at the earliest opportunity\r\n\r\n" \ "This message will not be reported again until SpamBayes\r\n"\ "is restarted." manager.ReportErrorOnce(err_msg) # Our objects. class MAPIMsgStore: # Stash exceptions in the class for ease of use by consumers. MsgStoreException = MsgStoreException NotFoundException = NotFoundException ReadOnlyException = ReadOnlyException ObjectChangedException = ObjectChangedException def __init__(self, outlook = None): self.outlook = outlook cwd = os.getcwd() # remember the cwd - mapi changes it under us! mapi.MAPIInitialize(None) logonFlags = (mapi.MAPI_NO_MAIL | mapi.MAPI_EXTENDED | mapi.MAPI_USE_DEFAULT) self.session = mapi.MAPILogonEx(0, None, None, logonFlags) # Note that if the CRT still has a default "C" locale, MAPILogonEx() # will change it. See locale comments in addin.py locale.setlocale(locale.LC_NUMERIC, "C") self.mapi_msg_stores = {} self.default_store_bin_eid = None os.chdir(cwd) def Close(self): self.mapi_msg_stores = None self.session.Logoff(0, 0, 0) self.session = None mapi.MAPIUninitialize() def GetProfileName(self): # Return the name of the MAPI profile currently in use. # XXX - note - early win32all versions are missing # GetStatusTable :( try: self.session.GetStatusTable except AttributeError: # We try and recover from this when win32all is updated, so no need to whinge. return None MAPI_SUBSYSTEM = 39 restriction = mapi.RES_PROPERTY, (mapi.RELOP_EQ, PR_RESOURCE_TYPE, (PR_RESOURCE_TYPE, MAPI_SUBSYSTEM)) table = self.session.GetStatusTable(0) rows = mapi.HrQueryAllRows(table, (PR_DISPLAY_NAME_A,), # columns to retrieve restriction, # only these rows None, # any sort order is fine 0) # any # of results is fine assert len(rows)==1, "Should be exactly one row" (tag, val), = rows[0] # I can't convince MAPI to give me the Unicode name, so we assume # encoded as MBCS. return val.decode("mbcs", "ignore") def _GetMessageStore(self, store_eid): # bin eid. try: # Will usually be pre-fetched, so fast-path out return self.mapi_msg_stores[store_eid] except KeyError: pass given_store_eid = store_eid if store_eid is None: # Find the EID for the default store. tab = self.session.GetMsgStoresTable(0) # Restriction for the table: get rows where PR_DEFAULT_STORE is true. # There should be only one. restriction = (mapi.RES_PROPERTY, # a property restriction (mapi.RELOP_EQ, # check for equality PR_DEFAULT_STORE, # of the PR_DEFAULT_STORE prop (PR_DEFAULT_STORE, True))) # with True rows = mapi.HrQueryAllRows(tab, (PR_ENTRYID,), # columns to retrieve restriction, # only these rows None, # any sort order is fine 0) # any # of results is fine # get first entry, a (property_tag, value) pair, for PR_ENTRYID row = rows[0] eid_tag, store_eid = row[0] self.default_store_bin_eid = store_eid # Open it. store = self.session.OpenMsgStore( 0, # no parent window store_eid, # msg store to open None, # IID; accept default IMsgStore # need write access to add score fields mapi.MDB_WRITE | # we won't send or receive email mapi.MDB_NO_MAIL | USE_DEFERRED_ERRORS) # cache it self.mapi_msg_stores[store_eid] = store if given_store_eid is None: # The default store self.mapi_msg_stores[None] = store return store def GetRootFolder(self, store_id = None): # if storeID is None, gets the root folder from the default store. store = self._GetMessageStore(store_id) hr, data = store.GetProps((PR_ENTRYID, PR_IPM_SUBTREE_ENTRYID), 0) store_eid = data[0][1] subtree_eid = data[1][1] eid = mapi.HexFromBin(store_eid), mapi.HexFromBin(subtree_eid) return self.GetFolder(eid) def _OpenEntry(self, id, iid = None, flags = None): # id is already normalized. store_id, item_id = id store = self._GetMessageStore(store_id) if flags is None: flags = mapi.MAPI_MODIFY | USE_DEFERRED_ERRORS return store.OpenEntry(item_id, iid, flags) # Normalize an "external" hex ID to an internal binary ID. def NormalizeID(self, item_id): assert type(item_id)==type(()), \ "Item IDs must be a tuple (not a %r)" % item_id try: store_id, entry_id = item_id return mapi.BinFromHex(store_id), mapi.BinFromHex(entry_id) except ValueError: raise MsgStoreException(None, "The specified ID '%s' is invalid" % (item_id,)) def _GetSubFolderIter(self, folder): table = folder.GetHierarchyTable(0) rows = mapi.HrQueryAllRows(table, (PR_ENTRYID, PR_STORE_ENTRYID, PR_DISPLAY_NAME_A), None, None, 0) for (eid_tag, eid), (store_eid_tag, store_eid), (name_tag, name) in rows: item_id = store_eid, eid sub = self._OpenEntry(item_id) table = sub.GetContentsTable(0) yield MAPIMsgStoreFolder(self, item_id, name, table.GetRowCount(0)) for store_folder in self._GetSubFolderIter(sub): yield store_folder def GetFolderGenerator(self, folder_ids, include_sub): for folder_id in folder_ids: try: folder_id = self.NormalizeID(folder_id) except MsgStoreException, details: print "NOTE: Skipping invalid folder", details continue try: folder = self._OpenEntry(folder_id) table = folder.GetContentsTable(0) except pythoncom.com_error, details: # We will ignore *all* such errors for the time # being, but give verbose details for results we don't # know about if IsNotAvailableCOMException(details): print "NOTE: Skipping folder for this session - temporarily unavailable" elif IsNotFoundCOMException(details): print "NOTE: Skipping deleted folder" else: print "WARNING: Unexpected MAPI error opening folder" print GetCOMExceptionString(details) continue rc, props = folder.GetProps( (PR_DISPLAY_NAME_A,), 0) yield MAPIMsgStoreFolder(self, folder_id, props[0][1], table.GetRowCount(0)) if include_sub: for f in self._GetSubFolderIter(folder): yield f def GetFolder(self, folder_id): # Return a single folder given the ID. try: # catch all MAPI errors try: # See if this is an Outlook folder item sid = mapi.BinFromHex(folder_id.StoreID) eid = mapi.BinFromHex(folder_id.EntryID) folder_id = sid, eid except AttributeError: # No 'EntryID'/'StoreID' properties - a 'normal' ID folder_id = self.NormalizeID(folder_id) folder = self._OpenEntry(folder_id) table = folder.GetContentsTable(0) # Ensure we have a long-term ID. rc, props = folder.GetProps( (PR_ENTRYID, PR_DISPLAY_NAME_A), 0) folder_id = folder_id[0], props[0][1] return MAPIMsgStoreFolder(self, folder_id, props[1][1], table.GetRowCount(0)) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def GetMessage(self, message_id): # Return a single message given either the ID, or an Outlook # message representing the object. try: # catch all MAPI exceptions. try: eid = mapi.BinFromHex(message_id.EntryID) sid = mapi.BinFromHex(message_id.Parent.StoreID) message_id = sid, eid except AttributeError: # No 'EntryID'/'StoreID' properties - a 'normal' ID message_id = self.NormalizeID(message_id) mapi_object = self._OpenEntry(message_id) hr, data = mapi_object.GetProps(MAPIMsgStoreMsg.message_init_props,0) return MAPIMsgStoreMsg(self, data) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def YieldReceiveFolders(self, msg_class = "IPM.Note"): # Get the main receive folder for each message store. tab = self.session.GetMsgStoresTable(0) rows = mapi.HrQueryAllRows(tab, (PR_ENTRYID,), # columns to retrieve None, # all rows None, # any sort order is fine 0) # any # of results is fine for row in rows: # get first entry, a (property_tag, value) pair, for PR_ENTRYID eid_tag, store_eid = row[0] try: store = self._GetMessageStore(store_eid) folder_eid, ret_class = store.GetReceiveFolder(msg_class, 0) hex_folder_eid = mapi.HexFromBin(folder_eid) hex_store_eid = mapi.HexFromBin(store_eid) except pythoncom.com_error, details: if not IsNotAvailableCOMException(details): print "ERROR enumerating a receive folder -", details continue try: folder = self.GetFolder((hex_store_eid, hex_folder_eid)) # For 'unconfigured' stores, or "stand-alone" PST files, # this is a root folder - so not what we wan't. Only return # folders with a parent. if folder.GetParent() is not None: yield folder except MsgStoreException, details: print "ERROR opening receive folder -", details # but we just continue continue _MapiTypeMap = { type(0.0): PT_DOUBLE, type(0): PT_I4, type(''): PT_STRING8, type(u''): PT_UNICODE, # In Python 2.2.2, bool isn't a distinct type (type(1==1) is type(0)). # type(1==1): PT_BOOLEAN, } def GetPropFromStream(mapi_object, prop_id): try: stream = mapi_object.OpenProperty(prop_id, pythoncom.IID_IStream, 0, 0) chunks = [] while 1: chunk = stream.Read(4096) if not chunk: break chunks.append(chunk) return "".join(chunks) except pythoncom.com_error, d: print "Error getting property", mapiutil.GetPropTagName(prop_id), \ "from stream:", d return "" def GetPotentiallyLargeStringProp(mapi_object, prop_id, row): got_tag, got_val = row if PROP_TYPE(got_tag) == PT_ERROR: ret = "" if got_val == mapi.MAPI_E_NOT_FOUND: pass # No property for this message. elif got_val == mapi.MAPI_E_NOT_ENOUGH_MEMORY: # Too big for simple properties - get via a stream ret = GetPropFromStream(mapi_object, prop_id) else: tag_name = mapiutil.GetPropTagName(prop_id) err_string = mapiutil.GetScodeString(got_val) print "Warning - failed to get property %s: %s" % (tag_name, err_string) else: ret = got_val return ret # Some nasty stuff for getting RTF out of the message def GetHTMLFromRTFProperty(mapi_object, prop_tag = PR_RTF_COMPRESSED): try: rtf_stream = mapi_object.OpenProperty(prop_tag, pythoncom.IID_IStream, 0, 0) html_stream = mapi.WrapCompressedRTFStream(rtf_stream, 0) html = mapi.RTFStreamToHTML(html_stream) except pythoncom.com_error, details: if not IsNotFoundCOMException(details): print "ERROR getting RTF body", details return "" # html may be None if RTF not originally from HTML, but here we # always want a string return html or '' class MAPIMsgStoreFolder: def __init__(self, msgstore, id, name, count): self.msgstore = msgstore self.id = id self.name = name self.count = count def __repr__(self): return "<%s '%s' (%d items), id=%s/%s>" % (self.__class__.__name__, self.name, self.count, mapi.HexFromBin(self.id[0]), mapi.HexFromBin(self.id[1])) def __eq__(self, other): if other is None: return False ceid = self.msgstore.session.CompareEntryIDs return ceid(self.id[0], other.id[0]) and \ ceid(self.id[1], other.id[1]) def __ne__(self, other): return not self.__eq__(other) def GetID(self): return mapi.HexFromBin(self.id[0]), mapi.HexFromBin(self.id[1]) def GetFQName(self): parts = [] parent = self while parent is not None: parts.insert(0, parent.name) try: # Ignore errors fetching parents - the caller just wants the # name - it may not be correctly 'fully qualified', but at # least we get something. parent = parent.GetParent() except MsgStoreException: break # We now end up with [0] being an empty string??, [1] being the # information store root folder name, etc. Outlook etc all just # use the information store name here. if parts and not parts[0]: del parts[0] # Don't catch exceptions on the item itself - that is fatal, # and should be caught by the caller. # Replace the "root" folder name with the information store name # as Outlook, our Folder selector etc do. mapi_store = self.msgstore._GetMessageStore(self.id[0]) hr, data = mapi_store.GetProps((PR_DISPLAY_NAME_A,), 0) name = data[0][1] if parts: # and replace with new name parts[0] = name else: # This can happen for the very root folder (ie, parent of the # top-level folder shown by Outlook. This folder should *never* # be used directly. parts = [name] print "WARNING: It appears you are using the top-level root of " \ "the information store as a folder. You probably don't "\ "want to do that" return "/".join(parts) def _FolderFromMAPIFolder(self, mapifolder): # Finally get the display name. hr, data = mapifolder.GetProps((PR_ENTRYID, PR_DISPLAY_NAME_A,), 0) eid = self.id[0], data[0][1] name = data[1][1] count = mapifolder.GetContentsTable(0).GetRowCount(0) return MAPIMsgStoreFolder(self.msgstore, eid, name, count) def GetParent(self): # return a folder object with the parent, or None if there is no # parent (ie, a top-level folder). Raises an exception if there is # an error fetching the parent (which implies something wrong with the # item itself, rather than this being top-level) try: folder = self.msgstore._OpenEntry(self.id) prop_ids = PR_PARENT_ENTRYID, hr, data = folder.GetProps(prop_ids,0) # Put parent ids together parent_eid = data[0][1] parent_id = self.id[0], parent_eid if hr != 0 or \ self.msgstore.session.CompareEntryIDs(parent_eid, self.id[1]): # No parent EID, or EID same as ours. return None parent = self.msgstore._OpenEntry(parent_id) # Finally get the item itself return self._FolderFromMAPIFolder(parent) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def OpenEntry(self, iid = None, flags = None): return self.msgstore._OpenEntry(self.id, iid, flags) def GetOutlookItem(self): try: hex_item_id = mapi.HexFromBin(self.id[1]) hex_store_id = mapi.HexFromBin(self.id[0]) return self.msgstore.outlook.Session.GetFolderFromID(hex_item_id, hex_store_id) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def GetMessageGenerator(self, only_filter_candidates = True): folder = self.OpenEntry() table = folder.GetContentsTable(0) table.SetColumns(MAPIMsgStoreMsg.message_init_props, 0) if only_filter_candidates: # Limit ourselves to IPM.* objects - ie, messages. restriction = (mapi.RES_PROPERTY, # a property restriction (mapi.RELOP_GE, # >= PR_MESSAGE_CLASS_A, # of the this prop (PR_MESSAGE_CLASS_A, "IPM."))) # with this value table.Restrict(restriction, 0) while 1: # Getting 70 at a time was the random number that gave best # perf for me ;) rows = table.QueryRows(70, 0) if len(rows) == 0: break for row in rows: # Our restriction helped, but may not have filtered # every message we don't want to touch. # Note no exception will be raised below if the message is # moved under us, as we don't need to access any properties. msg = MAPIMsgStoreMsg(self.msgstore, row) if not only_filter_candidates or msg.IsFilterCandidate(): yield msg def GetNewUnscoredMessageGenerator(self, scoreFieldName): folder = self.msgstore._OpenEntry(self.id) table = folder.GetContentsTable(0) # Resolve the field name resolve_props = ( (mapi.PS_PUBLIC_STRINGS, scoreFieldName), ) resolve_ids = folder.GetIDsFromNames(resolve_props, 0) field_id = PROP_TAG( PT_DOUBLE, PROP_ID(resolve_ids[0])) # Setup the properties we want to read. table.SetColumns(MAPIMsgStoreMsg.message_init_props, 0) # Set up the restriction # Need to check message-flags # (PR_CONTENT_UNREAD is optional, and somewhat unreliable # PR_MESSAGE_FLAGS & MSGFLAG_READ is the official way) prop_restriction = (mapi.RES_BITMASK, # a bitmask restriction (mapi.BMR_EQZ, # when bit is clear PR_MESSAGE_FLAGS, MSGFLAG_READ)) exist_restriction = mapi.RES_EXIST, (field_id,) not_exist_restriction = mapi.RES_NOT, (exist_restriction,) # A restriction for the message class class_restriction = (mapi.RES_PROPERTY, # a property restriction (mapi.RELOP_GE, # >= PR_MESSAGE_CLASS_A, # of the this prop (PR_MESSAGE_CLASS_A, "IPM."))) # with this value # Put the final restriction together restriction = (mapi.RES_AND, (prop_restriction, not_exist_restriction, class_restriction)) table.Restrict(restriction, 0) while 1: rows = table.QueryRows(70, 0) if len(rows) == 0: break for row in rows: # Note no exception will be raised below if the message is # moved under us, as we don't need to access any properties. msg = MAPIMsgStoreMsg(self.msgstore, row) if msg.IsFilterCandidate(): yield msg def IsReceiveFolder(self, msg_class = "IPM.Note"): # Is this folder the nominated "receive folder" for its store? try: mapi_store = self.msgstore._GetMessageStore(self.id[0]) eid, ret_class = mapi_store.GetReceiveFolder(msg_class, 0) return mapi_store.CompareEntryIDs(eid, self.id[1]) except pythoncom.com_error: # Error getting the receive folder from the store (or maybe our # store - but that would be insane!). Either way, we can't be it! return False def CreateFolder(self, name, comments = None, type = None, open_if_exists = False, flags = None): if type is None: type = mapi.FOLDER_GENERIC if flags is None: flags = 0 if open_if_exists: flags |= mapi.OPEN_IF_EXISTS folder = self.OpenEntry() ret = folder.CreateFolder(type, name, comments, None, flags) return self._FolderFromMAPIFolder(ret) def DoesFolderHaveOutlookField(self, field_name): # Returns True if the specified folder has an *Outlook* field with # the given name, False if the folder does not have it, or None # if we can't tell, or there was an error, etc. # We have discovered that Outlook stores 'Fields' for a folder as a # PR_USERFIELDS field in the hidden, 'associated' message with # message class IPC.MS.REN.USERFIELDS. This is a binary property # which is undocumented, but probably could be reverse-engineered # with a little effort (see 'dump_props --dump-folder-user-props' for # an example of the raw data. For now, the simplest thing appears # to be to check for a \0 character, followed by the property name # as an ascii string. try: folder = self.msgstore._OpenEntry(self.id) table = folder.GetContentsTable(mapi.MAPI_ASSOCIATED) restriction = (mapi.RES_PROPERTY, (mapi.RELOP_EQ, PR_MESSAGE_CLASS_A, (PR_MESSAGE_CLASS_A, 'IPC.MS.REN.USERFIELDS'))) cols = (PR_USERFIELDS,) table.SetColumns(cols, 0) rows = mapi.HrQueryAllRows(table, cols, restriction, None, 0) if len(rows)>1: print "Eeek - only expecting one row from IPC.MS.REN.USERFIELDS" print "got", repr(rows) return None if len(rows)==0: # New folders with no userdefined fields do not have such a row, # but this is a clear indication it does not exist. return False row = rows[0] val = GetPotentiallyLargeStringProp(folder, cols[0], row[0]) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) if type(val) != type(''): print "Value type incorrect - expected string, got", repr(val) return None return val.find("\0" + field_name) >= 0 def DeleteMessages(self, message_things): # A *permanent* delete - MAPI has no concept of 'Deleted Items', # only Outlook does. If you want a "soft" delete, you must locate # deleted item (via a special ID) and move it to there yourself # message_things may be ID tuples, or MAPIMsgStoreMsg instances. real_ids = [] for thing in message_things: if isinstance(thing, MAPIMsgStoreMsg): real_ids.append( thing.id[1] ) thing.mapi_object = thing.id = thing.folder_id = None else: real_ids.append(self.msgstore.NormalizeID(thing)[1]) try: folder = self.msgstore._OpenEntry(self.id) # Nuke my MAPI reference, and set my ID to None rc = folder.DeleteMessages(real_ids, 0, None, 0) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def CreateTemporaryMessage(self, msg_flags = None): # Create a message designed to be used temporarily. It is your # responsibility to delete when you are done with it. # If msg_flags is not None, it should be an integer for the # PR_MESSAGE_FLAGS property. Note that Outlook appears to refuse # to set user properties on a message marked as 'unsent', which # is the default. Setting to, eg, 1 marks it as a "not unsent, read" # message, which works fine with user properties. try: folder = self.msgstore._OpenEntry(self.id) imsg = folder.CreateMessage(None, 0) if msg_flags is not None: props = (PR_MESSAGE_FLAGS,msg_flags), imsg.SetProps(props) imsg.SaveChanges(0) hr, data = imsg.GetProps((PR_ENTRYID, PR_STORE_ENTRYID), 0) eid = data[0][1] storeid = data[1][1] msg_id = mapi.HexFromBin(storeid), mapi.HexFromBin(eid) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) return self.msgstore.GetMessage(msg_id) class MAPIMsgStoreMsg: # All the properties we must initialize a message with. # These include all the IDs we need, parent IDs, any properties needed # to determine if this is a "filterable" message, etc message_init_props = (PR_ENTRYID, PR_STORE_ENTRYID, PR_SEARCH_KEY, PR_PARENT_ENTRYID, # folder ID PR_MESSAGE_CLASS_A, # 'IPM.Note' etc PR_RECEIVED_BY_ENTRYID, # who received it PR_SUBJECT_A, PR_TRANSPORT_MESSAGE_HEADERS_A, ) def __init__(self, msgstore, prop_row): self.msgstore = msgstore self.mapi_object = None # prop_row is a single mapi property row, with fields as above. # NOTE: We can't trust these properties for "large" values # (ie, strings, PT_BINARY, objects etc.), as they sometimes come # from the IMAPITable (which has a 255 limit on property values) # and sometimes from the object itself (which has no restriction). # This limitation is documented by MAPI. # Thus, we don't trust "PR_TRANSPORT_MESSAGE_HEADERS_A" more than # to ask "does the property exist?" tag, eid = prop_row[0] # ID tag, store_eid = prop_row[1] tag, searchkey = prop_row[2] tag, parent_eid = prop_row[3] tag, msgclass = prop_row[4] recby_tag, recby = prop_row[5] tag, subject = prop_row[6] headers_tag, headers = prop_row[7] self.id = store_eid, eid self.folder_id = store_eid, parent_eid self.msgclass = msgclass self.subject = subject has_headers = PROP_TYPE(headers_tag)==PT_STRING8 # Search key is the only reliable thing after a move/copy operation # only problem is that it can potentially be changed - however, the # Outlook client provides no such (easy/obvious) way # (ie, someone would need to really want to change it ) # Thus, searchkey is our long-lived message key. self.searchkey = searchkey # To check if a message has ever been received, we check the # PR_RECEIVED_BY_ENTRYID flag. Tim wrote in an old comment that # An article on the web said the distinction can't be made with 100% # certainty, but that a good heuristic is to believe that a # msg has been received iff at least one of these properties # has a sensible value: RECEIVED_BY_EMAIL_ADDRESS, RECEIVED_BY_NAME, # RECEIVED_BY_ENTRYID PR_TRANSPORT_MESSAGE_HEADERS # But MarkH can't find it, and believes and tests that # PR_RECEIVED_BY_ENTRYID is all we need (but has since discovered a # couple of messages without any PR_RECEIVED_BY properties - but *with* # PR_TRANSPORT_MESSAGE_HEADERS - *sigh*) self.was_received = PROP_TYPE(recby_tag) == PT_BINARY or has_headers self.dirty = False def __repr__(self): if self.id is None: id_str = "(deleted/moved)" else: id_str = mapi.HexFromBin(self.id[0]), mapi.HexFromBin(self.id[1]) return "<%s, '%s' id=%s>" % (self.__class__.__name__, self.GetSubject(), id_str) # as per search-key comments above, we also "enforce" this at the Python # level. 2 different messages, but one copied from the other, will # return "==". # Not being consistent could cause subtle bugs, especially in interactions # with various test tools. # Compare the GetID() results if you need to know different messages. def __hash__(self): return hash(self.searchkey) def __eq__(self, other): ceid = self.msgstore.session.CompareEntryIDs return ceid(self.searchkey, other.searchkey) def __ne__(self, other): return not self.__eq__(other) def GetID(self): return mapi.HexFromBin(self.id[0]), mapi.HexFromBin(self.id[1]) def GetSubject(self): return self.subject def GetOutlookItem(self): hex_item_id = mapi.HexFromBin(self.id[1]) hex_store_id = mapi.HexFromBin(self.id[0]) return self.msgstore.outlook.Session.GetItemFromID(hex_item_id, hex_store_id) def IsFilterCandidate(self): # We don't attempt to filter: # * Non-mail items # * Messages that weren't actually received - this generally means user # composed messages yet to be sent, or copies of "sent items". # It does *not* exclude messages that were user composed, but still # actually received by the user (ie, when you mail yourself) # GroupWise generates IPM.Anti-Virus.Report.45 (but I'm not sure how # it manages given it is an external server, and as far as I can tell, # this does not appear in the headers. if test_suite_running: # While the test suite is running, we *only* filter test msgs. return self.subject == "SpamBayes addin auto-generated test message" class_check = self.msgclass.lower() for check in "ipm.note", "ipm.anti-virus": if class_check.startswith(check): break else: # Not matching class - no good return False # Must match msg class to get here. return self.was_received def _GetPotentiallyLargeStringProp(self, prop_id, row): return GetPotentiallyLargeStringProp(self.mapi_object, prop_id, row) def _GetMessageText(self): parts = self._GetMessageTextParts() # parts is (headers, body, html), but could possibly grow return "\n".join(parts) def _GetMessageTextParts(self): # This is almost reliable :). The only messages this now fails for # are for "forwarded" messages, where the forwards are actually # in an attachment. Later. # Note we *dont* look in plain text attachments, which we arguably # should. from spambayes import mboxutils self._EnsureObject() prop_ids = (PR_BODY_A, MYPR_BODY_HTML_A, PR_TRANSPORT_MESSAGE_HEADERS_A) hr, data = self.mapi_object.GetProps(prop_ids,0) body = self._GetPotentiallyLargeStringProp(prop_ids[0], data[0]) html = self._GetPotentiallyLargeStringProp(prop_ids[1], data[1]) headers = self._GetPotentiallyLargeStringProp(prop_ids[2], data[2]) # xxx - not sure what to do if we have both. if not html: html = GetHTMLFromRTFProperty(self.mapi_object) # Some Outlooks deliver a strange notion of headers, including # interior MIME armor. To prevent later errors, try to get rid # of stuff now that can't possibly be parsed as "real" (SMTP) # headers. headers = mboxutils.extract_headers(headers) # Mail delivered internally via Exchange Server etc may not have # headers - fake some up. if not headers: headers = self._GetFakeHeaders () # Mail delivered via the Exchange Internet Mail MTA may have # gibberish at the start of the headers - fix this. elif headers.startswith("Microsoft Mail"): headers = "X-MS-Mail-Gibberish: " + headers if not html and not body: # Only ever seen this for "multipart/signed" messages, so # without any better clues, just handle this. # Find all attachments with # PR_ATTACH_MIME_TAG_A=multipart/signed table = self.mapi_object.GetAttachmentTable(0) restriction = (mapi.RES_PROPERTY, # a property restriction (mapi.RELOP_EQ, # check for equality PR_ATTACH_MIME_TAG_A, # of the given prop (PR_ATTACH_MIME_TAG_A, "multipart/signed"))) try: rows = mapi.HrQueryAllRows(table, (PR_ATTACH_NUM,), # columns to get restriction, # only these rows None, # any sort order is fine 0) # any # of results is fine except pythoncom.com_error: # For some reason there are no rows we can get rows = [] if len(rows) == 0: pass # Nothing we can fetch :( else: if len(rows) > 1: print "WARNING: Found %d rows with multipart/signed" \ "- using first only" % len(rows) row = rows[0] (attach_num_tag, attach_num), = row assert attach_num_tag != PT_ERROR, \ "Error fetching attach_num prop" # Open the attachment attach = self.mapi_object.OpenAttach(attach_num, None, mapi.MAPI_DEFERRED_ERRORS) prop_ids = (PR_ATTACH_DATA_BIN,) hr, data = attach.GetProps(prop_ids, 0) attach_body = GetPotentiallyLargeStringProp(attach, prop_ids[0], data[0]) # What we seem to have here now is a *complete* multi-part # mime message - that Outlook must have re-constituted on # the fly immediately after pulling it apart! - not unlike # exactly what we are doing ourselves right here - putting # it into a message object, so we can extract the text, so # we can stick it back into another one. Ahhhhh. import email msg = email.message_from_string(attach_body) assert msg.is_multipart(), "Should be multi-part: %r" % attach_body # reduce down all sub messages, collecting all text/ subtypes. # (we could make a distinction between text and html, but # it is all joined together by this method anyway.) def collect_text_parts(msg): collected = '' if msg.is_multipart(): for sub in msg.get_payload(): collected += collect_text_parts(sub) else: if msg.get_content_maintype()=='text': collected += msg.get_payload() else: #print "skipping content type", msg.get_content_type() pass return collected body = collect_text_parts(msg) return headers, body, html def _GetFakeHeaders(self): # This is designed to fake up some SMTP headers for messages # on an exchange server that do not have such headers of their own prop_ids = PR_SUBJECT_A, PR_DISPLAY_NAME_A, PR_DISPLAY_TO_A, PR_DISPLAY_CC_A hr, data = self.mapi_object.GetProps(prop_ids,0) subject = self._GetPotentiallyLargeStringProp(prop_ids[0], data[0]) sender = self._GetPotentiallyLargeStringProp(prop_ids[1], data[1]) to = self._GetPotentiallyLargeStringProp(prop_ids[2], data[2]) cc = self._GetPotentiallyLargeStringProp(prop_ids[3], data[3]) headers = ["X-Exchange-Message: true"] if subject: headers.append("Subject: "+subject) if sender: headers.append("From: "+sender) if to: headers.append("To: "+to) if cc: headers.append("CC: "+cc) return "\n".join(headers) + "\n" def _EnsureObject(self): if self.mapi_object is None: try: help_test_suite("MAPIMsgStoreMsg._EnsureObject") self.mapi_object = self.msgstore._OpenEntry(self.id) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def GetEmailPackageObject(self, strip_mime_headers=True): # Return an email.Message object. # # strip_mime_headers is a hack, and should be left True unless you're # trying to display all the headers for diagnostic purposes. If we # figure out something better to do, it should go away entirely. # # Problem #1: suppose a msg is multipart/alternative, with # text/plain and text/html sections. The latter MIME decorations # are plain missing in what _GetMessageText() returns. If we leave # the multipart/alternative in the headers anyway, the email # package's "lax parsing" won't complain about not finding any # sections, but since the type *is* multipart/alternative then # anyway, the tokenizer finds no text/* parts at all to tokenize. # As a result, only the headers get tokenized. By stripping # Content-Type from the headers (if present), the email pkg # considers the body to be text/plain (the default), and so it # does get tokenized. # # Problem #2: Outlook decodes quoted-printable and base64 on its # own, but leaves any Content-Transfer-Encoding line in the headers. # This can cause the email pkg to try to decode the text again, # with unpleasant (but rarely fatal) results. If we strip that # header too, no problem -- although the fact that a msg was # encoded in base64 is usually a good spam clue, and we miss that. # # Short course: we either have to synthesize non-insane MIME # structure, or eliminate all evidence of original MIME structure. # Since we don't have a way to the former, by default this function # does the latter. import email text = self._GetMessageText() try: try: msg = email.message_from_string(text) except email.Errors.BoundaryError: # In case this is the # "No terminating boundary and no trailing empty line" # flavor of BoundaryError, we can supply a trailing empty # line to shut it up. It's certainly ill-formed MIME, and # probably spam. We don't care about the exact MIME # structure, just the words it contains, so no harm and # much good in trying to suppress this error. try: msg = email.message_from_string(text + "\n\n") except email.Errors.BoundaryError: msg = None except email.Errors.HeaderParseError: # This exception can come from parsing the header *or* the # body of a mime message. msg = None # But even this doesn't get *everything*. We can still see: # "multipart message with no defined boundary" or the # HeaderParseError above. Time to get brutal - hack out # the Content-Type header, so we see it as plain text. if msg is None: butcher_pos = text.lower().find("\ncontent-type: ") if butcher_pos < 0: # This error just just gunna get caught below anyway raise RuntimeError( "email package croaked with a MIME related error, but " "there appears to be no 'Content-Type' header") # Put it back together, skipping the original "\n" but # leaving the header leaving "\nSpamBayes-Content-Type: " butchered = text[:butcher_pos] + "\nSpamBayes-" + \ text[butcher_pos+1:] + "\n\n" msg = email.message_from_string(butchered) except: print "FAILED to create email.message from: ", `text` raise if strip_mime_headers: if msg.has_key('content-type'): del msg['content-type'] if msg.has_key('content-transfer-encoding'): del msg['content-transfer-encoding'] return msg def SetField(self, prop, val): # Future optimization note - from GetIDsFromNames doco # Name-to-identifier mapping is represented by an object's # PR_MAPPING_SIGNATURE property. PR_MAPPING_SIGNATURE contains # a MAPIUID structure that indicates the service provider # responsible for the object. If the PR_MAPPING_SIGNATURE # property is the same for two objects, assume that these # objects use the same name-to-identifier mapping. # [MarkH: MAPIUID objects are supported and hashable] # XXX If the SpamProb (Hammie, whatever) property is passed in as an # XXX int, Outlook displays the field as all blanks, and sorting on # XXX it doesn't do anything, etc. I don't know why. Since I'm # XXX running Python 2.2.2, the _MapiTypeMap above confuses ints # XXX with bools, but the problem persists even if I comment out the # XXX PT_BOOLEAN entry from that dict. Dumping in prints below show # XXX that type_tag is 3 then, and that matches the defn of PT_I4 in # XXX my system header files. # XXX Later: This works after all, but the field shows up as all # XXX blanks unless I *first* modify the view (like Messages) in # XXX Outlook to define a custom Integer field of the same name. self._EnsureObject() try: if type(prop) != type(0): props = ( (mapi.PS_PUBLIC_STRINGS, prop), ) propIds = self.mapi_object.GetIDsFromNames(props, mapi.MAPI_CREATE) type_tag = _MapiTypeMap.get(type(val)) if type_tag is None: raise ValueError, "Don't know what to do with '%r' ('%s')" % ( val, type(val)) prop = PROP_TAG(type_tag, PROP_ID(propIds[0])) help_test_suite("MAPIMsgStoreMsg.SetField") if val is None: # Delete the property self.mapi_object.DeleteProps((prop,)) else: self.mapi_object.SetProps(((prop,val),)) self.dirty = True except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def GetField(self, prop): # xxx - still raise_errors? self._EnsureObject() if type(prop) != type(0): props = ( (mapi.PS_PUBLIC_STRINGS, prop), ) prop = self.mapi_object.GetIDsFromNames(props, 0)[0] if PROP_TYPE(prop) == PT_ERROR: # No such property return None prop = PROP_TAG( PT_UNSPECIFIED, PROP_ID(prop)) try: hr, props = self.mapi_object.GetProps((prop,), 0) ((tag, val), ) = props if PROP_TYPE(tag) == PT_ERROR: if val == mapi.MAPI_E_NOT_ENOUGH_MEMORY: # Too big for simple properties - get via a stream return GetPropFromStream(self.mapi_object, prop) return None return val except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def GetReadState(self): val = self.GetField(PR_MESSAGE_FLAGS) return (val&MSGFLAG_READ) != 0 def SetReadState(self, is_read): try: self._EnsureObject() # always try and clear any pending delivery reports of read/unread help_test_suite("MAPIMsgStoreMsg.SetReadState") if is_read: self.mapi_object.SetReadFlag(USE_DEFERRED_ERRORS|SUPPRESS_RECEIPT) else: self.mapi_object.SetReadFlag(USE_DEFERRED_ERRORS|CLEAR_READ_FLAG) if __debug__: if self.GetReadState() != is_read: print "MAPI SetReadState appears to have failed to change the message state" print "Requested set to %s but the MAPI field after was %r" % \ (is_read, self.GetField(PR_MESSAGE_FLAGS)) except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def Save(self): assert self.dirty, "asking me to save a clean message!" # It seems that *not* specifying mapi.MAPI_DEFERRED_ERRORS solves a lot # problems! So we don't! try: help_test_suite("MAPIMsgStoreMsg.Save") self.mapi_object.SaveChanges(mapi.KEEP_OPEN_READWRITE) self.dirty = False except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def _DoCopyMove(self, folder, isMove): assert not self.dirty, \ "asking me to move a dirty message - later saves will fail!" try: dest_folder = self.msgstore._OpenEntry(folder.id) source_folder = self.msgstore._OpenEntry(self.folder_id) flags = 0 if isMove: flags |= MESSAGE_MOVE eid = self.id[1] help_test_suite("MAPIMsgStoreMsg._DoCopyMove") source_folder.CopyMessages((eid,), None, dest_folder, 0, None, flags) # At this stage, I think we have lost meaningful ID etc values # Set everything to None to make it clearer what is wrong should # this become an issue. We would need to re-fetch the eid of # the item, and set the store_id to the dest folder. self.id = None self.folder_id = None except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def MoveTo(self, folder): self._DoCopyMove(folder, True) def CopyTo(self, folder): self._DoCopyMove(folder, False) # Functions to perform operations, but report the error (ONCE!) to the # user. Any errors are re-raised so the caller can degrade gracefully if # necessary. # XXX - not too happy with these - they should go, and the caller should # handle (especially now that we work exclusively with exceptions from # this module. def MoveToReportingError(self, manager, folder): try: self.MoveTo(folder) except MsgStoreException, details: ReportMAPIError(manager, "Moving a message", details.mapi_exception) def CopyToReportingError(self, manager, folder): try: self.MoveTo(folder) except MsgStoreException, details: ReportMAPIError(manager, "Copying a message", details.mapi_exception) def GetFolder(self): # return a folder object with the parent, or None folder_id = (mapi.HexFromBin(self.folder_id[0]), mapi.HexFromBin(self.folder_id[1])) return self.msgstore.GetFolder(folder_id) def RememberMessageCurrentFolder(self): self._EnsureObject() try: folder = self.GetFolder() props = ( (mapi.PS_PUBLIC_STRINGS, "SpamBayesOriginalFolderStoreID"), (mapi.PS_PUBLIC_STRINGS, "SpamBayesOriginalFolderID") ) resolve_ids = self.mapi_object.GetIDsFromNames(props, mapi.MAPI_CREATE) prop_ids = PROP_TAG( PT_BINARY, PROP_ID(resolve_ids[0])), \ PROP_TAG( PT_BINARY, PROP_ID(resolve_ids[1])) prop_tuples = (prop_ids[0],folder.id[0]), (prop_ids[1],folder.id[1]) self.mapi_object.SetProps(prop_tuples) self.dirty = True except pythoncom.com_error, details: raise MsgStoreExceptionFromCOMException(details) def GetRememberedFolder(self): props = ( (mapi.PS_PUBLIC_STRINGS, "SpamBayesOriginalFolderStoreID"), (mapi.PS_PUBLIC_STRINGS, "SpamBayesOriginalFolderID") ) try: self._EnsureObject() resolve_ids = self.mapi_object.GetIDsFromNames(props, mapi.MAPI_CREATE) prop_ids = PROP_TAG( PT_BINARY, PROP_ID(resolve_ids[0])), \ PROP_TAG( PT_BINARY, PROP_ID(resolve_ids[1])) hr, data = self.mapi_object.GetProps(prop_ids,0) if hr != 0: return None (store_tag, store_id), (eid_tag, eid) = data folder_id = mapi.HexFromBin(store_id), mapi.HexFromBin(eid) help_test_suite("MAPIMsgStoreMsg.GetRememberedFolder") return self.msgstore.GetFolder(folder_id) except: print "Error locating origin of message", self return None def test(): from win32com.client import Dispatch outlook = Dispatch("Outlook.Application") inbox = outlook.Session.GetDefaultFolder(constants.olFolderInbox) folder_id = inbox.Parent.StoreID, inbox.EntryID store = MAPIMsgStore() for folder in store.GetFolderGenerator([folder_id,], True): print folder for msg in folder.GetMessageGenerator(): print msg store.Close() if __name__=='__main__': test()