Ejemplo n.º 1
0
class Server(object):
    """Server class"""
    def __init__(self,
                 options=None,
                 config=None,
                 sslkey_file=None,
                 sslkey_pass=None,
                 server_socket=None,
                 auth_user=None,
                 auth_pass=None,
                 log=None,
                 service=None,
                 mapisession=None,
                 parse_args=True,
                 notifications=False,
                 store_cache=True,
                 oidc=False,
                 _skip_check=False):
        """
        Create Server instance.

        By default, tries to connect to a storage server as configured in ``/etc/kopano/admin.cfg`` or
        at UNIX socket ``/var/run/kopano/server.sock``

        Looks at command-line to see if another server address or other related options were given (such as -c, -s, -k, -p)

        :param server_socket: similar to 'server_socket' option in config file
        :param sslkey_file: similar to 'sslkey_file' option in config file
        :param sslkey_pass: similar to 'sslkey_pass' option in config file
        :param config: path of configuration file containing common server options, for example ``/etc/kopano/admin.cfg``
        :param auth_user: username to user for user authentication
        :param auth_pass: password to use for user authentication
        :param options: OptionParser instance to get settings from (see :func:`parser`)
        :param parse_args: set this True if cli arguments should be parsed
        """
        self.options = options
        self.config = config
        self.sslkey_file = sslkey_file
        self.sslkey_pass = sslkey_pass
        self.server_socket = server_socket
        self.service = service
        if log:  # TODO deprecate?
            self.log = log
        elif service:
            self.log = service.log
        elif config:
            self.log = LOG
            self.log.setLevel(_loglevel(options, config))
        else:
            self.log = LOG
        self.mapisession = mapisession
        self.store_cache = store_cache

        if not _skip_check:
            warnings.warn('use kopano.server instead of kopano.Server',
                          _DeprecationWarning)

        if not self.mapisession:
            # get cmd-line options
            if not self.options:
                if parse_args:
                    self.options, _ = parser().parse_args()
                else:
                    self.options = Options()

            # determine config file
            if config:
                pass
            elif getattr(self.options, 'config_file', None):
                config_file = os.path.abspath(self.options.config_file)
                config = _config.Config(None,
                                        filename=self.options.config_file)
            else:
                config_file = '/etc/kopano/admin.cfg'
                try:
                    open(config_file)  # check if accessible
                    config = _config.Config(None, filename=config_file)
                except IOError:
                    pass
            self.config = config

            # get defaults
            if os.getenv("KOPANO_SOCKET"):  # env variable used in testset
                self.server_socket = os.getenv("KOPANO_SOCKET")
            elif config:
                if not (server_socket or getattr(self.options, 'server_socket',
                                                 None)):  # XXX generalize
                    self.server_socket = config.get('server_socket')
                    self.sslkey_file = config.get('sslkey_file')
                    self.sslkey_pass = config.get('sslkey_pass')
            self.server_socket = self.server_socket or "default:"

            # override with explicit or command-line args
            self.server_socket = server_socket or getattr(
                self.options, 'server_socket', None) or self.server_socket
            self.sslkey_file = sslkey_file or getattr(
                self.options, 'sslkey_file', None) or self.sslkey_file
            self.sslkey_pass = sslkey_pass or getattr(
                self.options, 'sslkey_pass', None) or self.sslkey_pass

            # make actual connection. in case of service, wait until this succeeds.
            self.auth_user = auth_user or getattr(self.options, 'auth_user',
                                                  None) or ''
            self.auth_pass = auth_pass or getattr(self.options, 'auth_pass',
                                                  None) or ''

            flags = 0
            self.notifications = notifications
            if not notifications:
                flags |= EC_PROFILE_FLAGS_NO_NOTIFICATIONS

            # Username and password was supplied, so let us do verfication
            # (OpenECSession will not check password unless this parameter is provided)
            if self.auth_user and self.auth_pass:
                flags |= EC_PROFILE_FLAGS_NO_UID_AUTH

            if oidc:
                flags |= EC_PROFILE_FLAGS_OIDC
            elif not self.auth_user:
                self.auth_user = "******"

            while True:
                try:
                    self.mapisession = OpenECSession(
                        self.auth_user,
                        self.auth_pass,
                        self.server_socket,
                        sslkey_file=self.sslkey_file,
                        sslkey_pass=self.sslkey_pass,
                        flags=flags)
                    break
                except (MAPIErrorNetworkError, MAPIErrorDiskError):
                    if service:
                        self.log.warning(
                            "could not connect to server at '%s', retrying in 5 sec",
                            self.server_socket)
                        time.sleep(5)
                    else:
                        raise Error("could not connect to server at '%s'" %
                                    self.server_socket)
                except MAPIErrorLogonFailed:
                    raise LogonError(
                        'Could not logon to server: username or password incorrect'
                    )

        self._mapistore = None
        self._sa = None
        self._ems = None
        self._ab = None
        self._admin_store = None
        self._gab = None
        self._pseudo_url = None
        self._name = None

    @property
    def mapistore(self):
        if self._mapistore is None:
            self._mapistore = GetDefaultStore(self.mapisession)
        return self._mapistore

    @property
    def sa(self):
        if self._sa is None:
            self._sa = self.mapistore.QueryInterface(IID_IECServiceAdmin)
        return self._sa

    @property
    def ems(self):
        if self._ems is None:
            self._ems = self.mapistore.QueryInterface(IID_IExchangeManageStore)
        return self._ems

    @property
    def pseudo_url(self):
        if self._pseudo_url is None:
            entryid = HrGetOneProp(self.mapistore, PR_STORE_ENTRYID).Value
            self._pseudo_url = entryid[entryid.find(b'pseudo:'):
                                       -1]  # XXX ECSERVER
        return self._pseudo_url

    @property
    def name(self):
        if self._name is None:
            self._name = self.pseudo_url[9:].decode(
                'ascii'
            )  # XXX encoding, get this kind of stuff from pr_ec_statstable_servers..?
        return self._name

    def nodes(self):  # XXX delay mapi sessions until actually needed
        for row in self.table(PR_EC_STATSTABLE_SERVERS).dict_rows():
            yield Server(options=self.options,
                         config=self.config,
                         sslkey_file=self.sslkey_file,
                         sslkey_pass=self.sslkey_pass,
                         server_socket=codecs.decode(
                             row[PR_EC_STATS_SERVER_HTTPSURL], 'utf-8'),
                         log=self.log,
                         service=self.service,
                         _skip_check=True)

    def table(self, name, restriction=None, order=None, columns=None):
        return Table(
            self,
            self.mapistore,
            self.mapistore.OpenProperty(name, IID_IMAPITable, MAPI_UNICODE, 0),
            name,
            restriction=restriction,
            order=order,
            columns=columns,
        )

    def tables(self):
        for table in (PR_EC_STATSTABLE_SYSTEM, PR_EC_STATSTABLE_SESSIONS,
                      PR_EC_STATSTABLE_USERS, PR_EC_STATSTABLE_COMPANY,
                      PR_EC_STATSTABLE_SERVERS):
            try:
                yield self.table(table)
            except MAPIErrorNotFound:
                pass

    def gab_table(
        self,
        restriction=None,
        columns=None
    ):  # XXX separate addressbook class? useful to add to self.tables?
        ct = self.gab.GetContentsTable(MAPI_DEFERRED_ERRORS)
        return Table(
            self,
            self.mapistore,
            ct,
            PR_CONTAINER_CONTENTS,
            restriction=restriction,
            columns=columns,
        )

    @property
    def ab(self):
        if not self._ab:
            self._ab = self.mapisession.OpenAddressBook(0, None, 0)  # XXX
        return self._ab

    @property
    def admin_store(self):
        if not self._admin_store:
            self._admin_store = _store.Store(mapiobj=self.mapistore,
                                             server=self)
        return self._admin_store

    @property
    def gab(self):
        if not self._gab:
            self._gab = self.ab.OpenEntry(self.ab.GetDefaultDir(), None, 0)
        return self._gab

    @property
    def guid(self):
        """Server GUID."""
        return _benc(HrGetOneProp(self.mapistore, PR_MAPPING_SIGNATURE).Value)

    def user(self, name=None, email=None, create=False, userid=None):
        """Return :class:`user <User>` with given name or email address.

        :param name: user name
        :param email: email address
        :param create: create user if it doesn't exist (name required)
        """

        if not (name or email or userid):
            raise ArgumentError('missing argument to identify user')

        try:
            return _user.User(name, email=email, server=self, userid=userid)
        except NotFoundError:
            if create and name:
                return self.create_user(name)
            else:
                raise

    def get_user(self, name):
        """Return :class:`user <User>` with given name or *None* if not found."""
        try:
            return self.user(name)
        except NotFoundError:
            pass

    def users(self,
              remote=False,
              system=False,
              parse=True,
              page_start=None,
              page_limit=None,
              order=None,
              hidden=True,
              inactive=True,
              _server=None,
              _company=None,
              query=None):
        """Return all :class:`users <User>` on server.

        :param remote: include users on remote server nodes
        :param system: include system users
        """
        pos = 0
        count = 0

        multitenant = self.multitenant

        # use query
        if query is not None:
            store = _store.Store(mapiobj=self.mapistore, server=self)
            restriction = _query_to_restriction(
                query, 'user',
                store)  # TODO use restriction to filter company!
            columns = [PR_ENTRYID, PR_DISPLAY_NAME_W, PR_SMTP_ADDRESS_W]
            table = self.gab_table(restriction=restriction, columns=columns)
            for row in table.rows():
                userid = _benc(row[0].value)
                try:
                    user = self.user(userid=userid)
                except NotFoundError:
                    self.log.warning('could not open user with userid "%s"',
                                     userid)
                    continue
                if multitenant and _company and _company != user.company:
                    continue
                if page_start is None or pos >= page_start:
                    yield user
                    count += 1
                if page_limit is not None and count >= page_limit:
                    return
                pos += 1
            return

        def include(user, ecuser):
            return ((system or user.name != u'SYSTEM')
                    and (remote or ecuser.Servername in (self.name, ''))
                    and (hidden or not user.hidden)
                    and (inactive or user.active))

        # users specified on command-line
        if parse and getattr(self.options, 'users', None):
            for username in self.options.users:
                yield _user.User(username, self)
            return

        # multi-tenant: get users per company
        if multitenant:
            if _company:
                companies = [_company]
            else:
                companies = [
                    Company(name, self) for name in self._companylist()
                ]  # TODO slow
            for company in companies:
                for ecuser in self.sa.GetUserList(_bdec(company.companyid),
                                                  MAPI_UNICODE):
                    user = _user.User(server=self, ecuser=ecuser)
                    if include(user, ecuser):
                        if page_start is None or pos >= page_start:
                            yield user
                            count += 1
                        if page_limit is not None and count >= page_limit:
                            return
                        pos += 1

        # single-tenant: get all users
        else:
            for ecuser in self.sa.GetUserList(None, MAPI_UNICODE):
                user = _user.User(server=self, ecuser=ecuser)
                if include(user, ecuser):
                    if page_start is None or pos >= page_start:
                        yield user
                        count += 1
                    if page_limit is not None and count >= page_limit:
                        return
                    pos += 1

    def create_user(self,
                    name,
                    email=None,
                    password=None,
                    company=None,
                    fullname=None,
                    create_store=True):
        """Create a new :class:`user <User>` on the server.

        :param name: the login name of the user
        :param email: the email address of the user
        :param password: the login password of the user
        :param company: the company of the user
        :param fullname: the full name of the user
        :param create_store: should a store be created for the new user
        :return: :class:`<User>`
        """
        fullname = _unicode(fullname or name or '')
        name = _unicode(name)
        if email:
            email = _unicode(email)
        else:
            email = u'%s@%s' % (name, socket.gethostname())
        if password:
            password = _unicode(password)
        if company:
            company = _unicode(company)
        if company and company != u'Default':
            self.sa.CreateUser(
                ECUSER(u'%s@%s' % (name, company), password, email, fullname),
                MAPI_UNICODE)
            user = self.company(company).user(u'%s@%s' % (name, company))
        else:
            try:
                self.sa.CreateUser(ECUSER(name, password, email, fullname),
                                   MAPI_UNICODE)
            except MAPIErrorNoSupport:
                raise NotSupportedError(
                    "cannot create users with configured user plugin")
            except MAPIErrorCollision:
                raise DuplicateError("user '%s' already exists" % name)
            user = self.user(name)
        if create_store:
            try:
                self.sa.CreateStore(ECSTORE_TYPE_PRIVATE, _bdec(user.userid))
            except MAPIErrorCollision:
                pass  # create-user userscript may already create store
        return user

    def remove_user(self, name):  # XXX delete(object)?
        """Remove a user

        :param name: the login name of the user
        """
        user = self.user(name)
        self.sa.DeleteUser(user._ecuser.UserID)

    def company(self, name, create=False):
        """Return :class:`company <Company>` with given name.

        :param name: company name
        :param create: create company if it doesn't exist
        """
        try:
            return Company(name, self)
        except MAPIErrorNoSupport:
            raise NotFoundError('no such company: %s' % name)
        except NotFoundError:
            if create:
                return self.create_company(name)
            else:
                raise

    def get_company(self, name):
        """:class:`company <Company>` with given name

        :param name: the company name
        :return: :class:`company <Company>` with given name or *None* if not found.
        """
        try:
            return self.company(name)
        except NotFoundError:
            pass

    def remove_company(self, name):  # XXX delete(object)?
        company = self.company(name)

        if company.name == u'Default':
            raise NotSupportedError(
                'cannot remove company in single-tenant mode')
        else:
            self.sa.DeleteCompany(company._eccompany.CompanyID)

    def _companylist(self):
        return [
            eccompany.Companyname
            for eccompany in self.sa.GetCompanyList(MAPI_UNICODE)
        ]

    @property
    def multitenant(self):
        """The server is multi-tenant."""
        try:
            self._companylist()
            return True
        except MAPIErrorNoSupport:
            return False

    def companies(self, remote=False, parse=True):  # XXX remote?
        """Return all :class:`companies <Company>` on server.

        :param remote: include companies without users on this server node (default False)
        :param parse: take cli argument --companies into account (default True)
        :return: Generator of :class:`companies <Company>` on server.
        """
        if parse and getattr(self.options, 'companies', None):
            for name in self.options.companies:
                try:
                    yield Company(name, self)
                except MAPIErrorNoSupport:
                    raise NotFoundError('no such company: %s' % name)
            return
        try:
            for name in self._companylist():
                yield Company(name, self)
        except MAPIErrorNoSupport:
            yield Company(u'Default', self)

    def create_company(self, name):
        """Create a new :class:`company <Company>` on the server.

        :param name: the name of the company
        """
        name = _unicode(name)
        try:
            self.sa.CreateCompany(ECCOMPANY(name, None), MAPI_UNICODE)
        except MAPIErrorCollision:
            raise DuplicateError("company '%s' already exists" % name)
        except MAPIErrorNoSupport:
            raise NotSupportedError(
                "cannot create company in single-tenant mode")
        return self.company(name)

    def _store(self, guid):  # TODO move guid checks to caller
        if len(guid) != 32:
            raise Error("invalid store id: '%s'" % guid)
        try:
            storeid = _bdec(guid)
        except:
            raise Error("invalid store id: '%s'" % guid)
        table = self.ems.GetMailboxTable(None,
                                         0)  # XXX merge with Store.__init__
        table.SetColumns([PR_ENTRYID], 0)
        table.Restrict(
            SPropertyRestriction(RELOP_EQ, PR_STORE_RECORD_KEY,
                                 SPropValue(PR_STORE_RECORD_KEY, storeid)),
            TBL_BATCH)
        for row in table.QueryRows(-1, 0):
            return self._store2(row[0].Value)
        raise NotFoundError("no such store: '%s'" % guid)

    @instance_method_lru_cache(128
                               )  # backend doesn't like too many open stores
    def _store_cached(self, storeid):
        return self.mapisession.OpenMsgStore(0, storeid, IID_IMsgStore,
                                             MDB_WRITE)

    def _store2(self, storeid):  # TODO max lifetime?
        if self.store_cache:
            return self._store_cached(storeid)
        else:
            return self.mapisession.OpenMsgStore(0, storeid, IID_IMsgStore,
                                                 MDB_WRITE)

    def groups(self):
        """Return all :class:`groups <Group>` on server."""
        try:
            for ecgroup in self.sa.GetGroupList(None, MAPI_UNICODE):
                yield Group(ecgroup.Groupname, self)
        except NotFoundError:  # XXX what to do here (single-tenant..), as groups do exist?
            pass

    def group(self, name, create=False):
        """Return :class:`group <Group>` with given name.

        :param name: group name
        :param create: create group if it doesn't exist
        """
        try:
            return Group(name, self)
        except NotFoundError:
            if create:
                return self.create_group(name)
            else:
                raise

    def create_group(self,
                     name,
                     fullname='',
                     email='',
                     hidden=False,
                     groupid=None):
        """Create a new :class:`group <Group>` on the server.

        :param name: the name of the group
        :param fullname: the full name of the group (optional)
        :param email: the email address of the group (optional)
        :param hidden: hide the group (optional)
        :param groupid: the id of the group (optional)
        :return: :class:`group <Group>` the created group
        """
        name = _unicode(name)  # XXX: fullname/email unicode?
        email = _unicode(email)
        fullname = _unicode(fullname)
        try:
            self.sa.CreateGroup(
                ECGROUP(name, fullname, email, int(hidden), groupid),
                MAPI_UNICODE)
        except MAPIErrorNoSupport:
            raise NotSupportedError(
                "cannot create groups with configured user plugin")
        except MAPIErrorCollision:
            raise DuplicateError("group '%s' already exists" % name)

        return self.group(name)

    def remove_group(self, name):
        group = self.group(name)
        self.sa.DeleteGroup(group._ecgroup.GroupID)

    def delete(self, objects):
        """Delete users, groups, companies or stores from server.

        :param objects: The object(s) to delete
        """
        objects = _utils.arg_objects(
            objects, (_user.User, Group, Company, _store.Store),
            'Server.delete')

        for item in objects:
            if isinstance(item, _user.User):
                self.remove_user(item.name)
            elif isinstance(item, Group):
                self.remove_group(item.name)
            elif isinstance(item, Company):
                self.remove_company(item.name)
            elif isinstance(item, _store.Store):
                self.remove_store(item)

    def _pubstore(self, name):
        if name == 'public':
            if not self.public_store:
                raise NotFoundError("no public store")
            return self.public_store
        else:
            company = Company(name.split('@')[1], self)
            if not company.public_store:
                raise NotFoundError("no public store for company '%s'" %
                                    company.name)
            return company.public_store

    def store(self, guid=None, entryid=None):
        """Return :class:`store <Store>` with given GUID."""
        if _is_str(guid) and _unicode(guid).split('@')[0] == 'public':
            return self._pubstore(guid)
        else:
            return _store.Store(guid=guid, entryid=entryid, server=self)

    def get_store(self, guid):
        """Return :class:`store <Store>` with given GUID or *None* if not found."""
        try:
            return self.store(guid)
        except Error:
            pass

    def stores(self,
               system=False,
               remote=False,
               parse=True):  # XXX implement remote
        """Return all :class:`stores <Store>` on server node.

        :param system: include system stores
        :param remote: include stores on other nodes
        """
        if parse and getattr(self.options, 'stores', None):
            for guid in self.options.stores:
                if guid.split('@')[0] == 'public':
                    yield self._pubstore(guid)
                elif len(guid) == 32:
                    yield _store.Store(guid=guid, server=self)
                else:
                    yield _store.Store(entryid=guid, server=self)
            return

        table = self.ems.GetMailboxTable(None, 0)
        table.SetColumns([PR_DISPLAY_NAME_W, PR_ENTRYID], 0)
        for row in table.QueryRows(-1, 0):
            store = _store.Store(mapiobj=self._store2(row[1].Value),
                                 server=self)
            if not system and store.user and store.user.name == 'SYSTEM':
                continue
            yield store

    def create_store(self, user, _msr=False):
        # TODO detect homeserver override
        storetype = ECSTORE_TYPE_PRIVATE
        # TODO configurable storetype

        if _msr:
            try:
                storeid, rootid = self.sa.CreateEmptyStore(
                    storetype, _bdec(user.userid), EC_OVERRIDE_HOMESERVER,
                    None, None)
            except MAPIErrorCollision:
                raise DuplicateError(
                    "user '%s' already has an associated store (unhook first?)"
                    % user.name)
            store_entryid = WrapStoreEntryID(
                0, b'zarafa6client.dll',
                storeid[:-4]) + b'https://' + codecs.encode(
                    self.name, 'utf-8') + b':237\x00'
            store_entryid = store_entryid[:66] + b'\x10' + store_entryid[
                67:]  # multi-server flag
            store = self.store(entryid=_benc(store_entryid))

            # TODO add EC_OVERRIDE_HOMESERVER flag to CreateStore instead? or how does the old MSR do this?

            # TODO language

            # system folders
            root = store.root

            store.findroot = root.create_folder('FINDER_ROOT')
            store.findroot.permission(self.group('Everyone'),
                                      create=True).rights = [
                                          'read_items', 'create_subfolders',
                                          'edit_own', 'delete_own',
                                          'folder_visible'
                                      ]

            store.views = root.create_folder('IPM_VIEWS')
            store.common_views = root.create_folder('IPM_COMMON_VIEWS')

            root.create_folder('Freebusy Data')
            root.create_folder('Schedule')
            root.create_folder('Shortcut')

            # special folders
            subtree = store.subtree = root.folder('IPM_SUBTREE', create=True)

            store.calendar = subtree.create_folder('Calendar')
            store.contacts = subtree.create_folder('Contacts')
            # TODO Conversation Action Settings?
            store.wastebasket = subtree.create_folder('Deleted Items')
            store.drafts = subtree.create_folder('Drafts')
            store.inbox = subtree.create_folder('Inbox')
            store.journal = subtree.create_folder('Journal')
            store.junk = subtree.create_folder('Junk E-mail')
            store.notes = subtree.create_folder('Notes')
            store.outbox = subtree.create_folder('Outbox')
            # TODO Quick Step Settings?
            # TODO RSS Feeds?
            store.sentmail = subtree.create_folder('Sent Items')
            # TODO Suggested Contacts?
            store.tasks = subtree.create_folder('Tasks')

            # freebusy message TODO create dynamically instead?
            fbmsg = root.create_item(
                subject='LocalFreebusy',
                message_class='IPM.Microsoft.ScheduleData.FreeBusy')
            root[PR_FREEBUSY_ENTRYIDS] = [b'', _bdec(fbmsg.entryid), b'', b'']

        else:
            store = user.create_store()
        return store

    def remove_store(self, store):
        try:
            self.sa.RemoveStore(_bdec(store.guid))
        except MAPIErrorCollision:
            raise Error("cannot remove store with GUID '%s'" % store.guid)

    def sync_users(self):
        self.sa.SyncUsers(None)

    def clear_cache(self):  # XXX specify one or more caches?
        self.sa.PurgeCache(PURGE_CACHE_ALL)

    def purge_softdeletes(self, days):
        self.sa.PurgeSoftDelete(days)

    def purge_deferred(self):  # XXX purge all at once?
        try:
            return self.sa.PurgeDeferredUpdates()  # remaining records
        except MAPIErrorNotFound:
            return 0

    def _pubhelper(self):
        try:
            self.sa.GetCompanyList(MAPI_UNICODE)
            raise NotSupportedError(
                'request for server-wide public store in multi-tenant setup')
        except MAPIErrorNoSupport:
            return next(self.companies())

    @property
    def public_store(self):
        """Public :class:`store <Store>` in single-tenant mode."""
        return self._pubhelper().public_store

    def create_public_store(self):
        """Create public :class:`store <Store>` in single-tenant mode."""
        return self._pubhelper().create_public_store()

    def hook_public_store(self, store):
        """Hook public :class:`store <Store>` in single-tenant mode.

        :param store: store to hook
        """
        return self._pubhelper().hook_public_store(store)

    def unhook_public_store(self):
        """Unhook public :class:`store <Store>` in single-tenant mode."""
        return self._pubhelper().unhook_public_store()

    @property
    def state(self):
        """Current server state."""
        return _ics.state(self.mapistore)

    def sync(self,
             importer,
             state,
             log=None,
             max_changes=None,
             window=None,
             begin=None,
             end=None,
             stats=None):
        """Perform synchronization against server node.

        :param importer: importer instance with callbacks to process changes
        :param state: start from this state (has to be given)
        :log: logger instance to receive important warnings/errors
        """
        importer.store = None
        return _ics.sync(self,
                         self.mapistore,
                         importer,
                         state,
                         max_changes,
                         window=window,
                         begin=begin,
                         end=end,
                         stats=stats)

    def sync_gab(self, importer, state=None):
        if state is None:
            state = _benc(8 * b'\0')
        return _ics.sync_gab(self, self.mapistore, importer, state)

    def stats(self):
        table = self.table(PR_EC_STATSTABLE_SYSTEM)
        stats = {}
        for key, value in table.dict_(
                PR_DISPLAY_NAME,
                PR_EC_STATS_SYSTEM_VALUE).items():  # TODO use *_W?
            stats[key] = value

        # XXX shouldn't be necessary
        stats = dict([(s.decode('UTF-8'), stats[s].decode('UTF-8'))
                      for s in stats])

        return stats

    def stat(self, key):  # TODO optimize with restriction?
        return self.stats()[key]

    def get_stat(self, key, default=None):
        return self.stats().get(key, default)

    @_timed_cache(minutes=60)
    def _resolve_email(self, entryid=None):
        try:
            mailuser = self.mapisession.OpenEntry(entryid, None, 0)
            return self.user(HrGetOneProp(mailuser, PR_ACCOUNT_W).Value
                             ).email  # XXX PR_SMTP_ADDRESS_W from mailuser?
        except (Error, MAPIErrorNotFound):  # XXX deleted user
            return ''  # XXX groups

    def __unicode__(self):
        return u'Server(%s)' % self.server_socket

    def __repr__(self):
        return _repr(self)
Ejemplo n.º 2
0
class Server(object):
    """Server class"""

    def __init__(self, options=None, config=None, sslkey_file=None, sslkey_pass=None, server_socket=None, auth_user=None, auth_pass=None, log=None, service=None, mapisession=None, parse_args=True, notifications=False, store_cache=True, oidc=False):
        """
        Create Server instance.

        By default, tries to connect to a storage server as configured in ``/etc/kopano/admin.cfg`` or
        at UNIX socket ``/var/run/kopano/server.sock``

        Looks at command-line to see if another server address or other related options were given (such as -c, -s, -k, -p)

        :param server_socket: similar to 'server_socket' option in config file
        :param sslkey_file: similar to 'sslkey_file' option in config file
        :param sslkey_pass: similar to 'sslkey_pass' option in config file
        :param config: path of configuration file containing common server options, for example ``/etc/kopano/admin.cfg``
        :param auth_user: username to user for user authentication
        :param auth_pass: password to use for user authentication
        :param options: OptionParser instance to get settings from (see :func:`parser`)
        :param parse_args: set this True if cli arguments should be parsed
        """
        self.options = options
        self.config = config
        self.sslkey_file = sslkey_file
        self.sslkey_pass = sslkey_pass
        self.server_socket = server_socket
        self.service = service
        if log: # TODO deprecate?
            self.log = log
        elif service:
            self.log = service.log
        else:
            self.log = LOG
        self.mapisession = mapisession
        self.store_cache = store_cache

        if not self.mapisession:
            # get cmd-line options
            if parse_args and not self.options:
                self.options, args = parser().parse_args()

            # determine config file
            if config:
                pass
            elif getattr(self.options, 'config_file', None):
                config_file = os.path.abspath(self.options.config_file)
                config = _config.Config(None, filename=self.options.config_file)
            else:
                config_file = '/etc/kopano/admin.cfg'
                try:
                    open(config_file) # check if accessible
                    config = _config.Config(None, filename=config_file)
                except IOError:
                    pass
            self.config = config

            # get defaults
            if os.getenv("KOPANO_SOCKET"): # env variable used in testset
                self.server_socket = os.getenv("KOPANO_SOCKET")
            elif config:
                if not (server_socket or getattr(self.options, 'server_socket')): # XXX generalize
                    self.server_socket = config.get('server_socket')
                    self.sslkey_file = config.get('sslkey_file')
                    self.sslkey_pass = config.get('sslkey_pass')
            self.server_socket = self.server_socket or "default:"

            # override with explicit or command-line args
            self.server_socket = server_socket or getattr(self.options, 'server_socket', None) or self.server_socket
            self.sslkey_file = sslkey_file or getattr(self.options, 'sslkey_file', None) or self.sslkey_file
            self.sslkey_pass = sslkey_pass or getattr(self.options, 'sslkey_pass', None) or self.sslkey_pass

            # make actual connection. in case of service, wait until this succeeds.
            self.auth_user = auth_user or getattr(self.options, 'auth_user', None) or ''
            self.auth_pass = auth_pass or getattr(self.options, 'auth_pass', None) or ''

            flags = 0
            if not notifications:
                flags |= EC_PROFILE_FLAGS_NO_NOTIFICATIONS

            # Username and password was supplied, so let us do verfication
            # (OpenECSession will not check password unless this parameter is provided)
            if self.auth_user and self.auth_pass:
                flags |= EC_PROFILE_FLAGS_NO_UID_AUTH

            if oidc:
                flags |= EC_PROFILE_FLAGS_OIDC
            elif not self.auth_user:
                self.auth_user = "******"

            while True:
                try:
                    self.mapisession = OpenECSession(self.auth_user, self.auth_pass, self.server_socket, sslkey_file=self.sslkey_file, sslkey_pass=self.sslkey_pass, flags=flags)
                    break
                except (MAPIErrorNetworkError, MAPIErrorDiskError):
                    if service:
                        self.log.warn("could not connect to server at '%s', retrying in 5 sec" % self.server_socket)
                        time.sleep(5)
                    else:
                        raise Error("could not connect to server at '%s'" % self.server_socket)
                except MAPIErrorLogonFailed:
                    raise LogonError('Could not logon to server: username or password incorrect')

        self._mapistore = None
        self._sa = None
        self._ems = None
        self._ab = None
        self._admin_store = None
        self._gab = None
        self._pseudo_url = None
        self._name = None

    @property
    def mapistore(self):
        if self._mapistore is None:
            self._mapistore = GetDefaultStore(self.mapisession)
        return self._mapistore

    @property
    def sa(self):
        if self._sa is None:
            self._sa = self.mapistore.QueryInterface(IID_IECServiceAdmin)
        return self._sa

    @property
    def ems(self):
        if self._ems is None:
            self._ems = self.mapistore.QueryInterface(IID_IExchangeManageStore)
        return self._ems

    @property
    def pseudo_url(self):
        if self._pseudo_url is None:
            entryid = HrGetOneProp(self.mapistore, PR_STORE_ENTRYID).Value
            self._pseudo_url = entryid[entryid.find(b'pseudo:'):-1] # XXX ECSERVER
        return self._pseudo_url

    @property
    def name(self):
        if self._name is None:
            self._name = self.pseudo_url[9:].decode('ascii') # XXX encoding, get this kind of stuff from pr_ec_statstable_servers..?
        return self._name

    def nodes(self): # XXX delay mapi sessions until actually needed
        for row in self.table(PR_EC_STATSTABLE_SERVERS).dict_rows():
            yield Server(options=self.options, config=self.config, sslkey_file=self.sslkey_file, sslkey_pass=self.sslkey_pass, server_socket=row[PR_EC_STATS_SERVER_HTTPSURL], log=self.log, service=self.service)

    def table(self, name, restriction=None, order=None, columns=None):
        return Table(
            self,
            self.mapistore,
            self.mapistore.OpenProperty(name, IID_IMAPITable, MAPI_UNICODE, 0),
            name,
            restriction=restriction,
            order=order,
            columns=columns,
        )

    def tables(self):
        for table in (PR_EC_STATSTABLE_SYSTEM, PR_EC_STATSTABLE_SESSIONS, PR_EC_STATSTABLE_USERS, PR_EC_STATSTABLE_COMPANY, PR_EC_STATSTABLE_SERVERS):
            try:
                yield self.table(table)
            except MAPIErrorNotFound:
                pass

    def gab_table(self, restriction=None, columns=None): # XXX separate addressbook class? useful to add to self.tables?
        ct = self.gab.GetContentsTable(MAPI_DEFERRED_ERRORS)
        return Table(
            self,
            self.mapistore,
            ct,
            PR_CONTAINER_CONTENTS,
            restriction=restriction,
            columns=columns,
        )

    @property
    def ab(self):
        if not self._ab:
            self._ab = self.mapisession.OpenAddressBook(0, None, 0) # XXX
        return self._ab

    @property
    def admin_store(self):
        if not self._admin_store:
            self._admin_store = _store.Store(mapiobj=self.mapistore, server=self)
        return self._admin_store

    @property
    def gab(self):
        if not self._gab:
            self._gab = self.ab.OpenEntry(self.ab.GetDefaultDir(), None, 0)
        return self._gab

    @property
    def guid(self):
        """Server GUID."""
        return _benc(HrGetOneProp(self.mapistore, PR_MAPPING_SIGNATURE).Value)

    def user(self, name=None, email=None, create=False, userid=None):
        """Return :class:`user <User>` with given name or email address.

        :param name: user name
        :param email: email address
        :param create: create user if it doesn't exist (name required)
        """
        try:
            return _user.User(name, email=email, server=self, userid=userid)
        except NotFoundError:
            if create and name:
                return self.create_user(name)
            else:
                raise

    def get_user(self, name):
        """Return :class:`user <User>` with given name or *None* if not found."""
        try:
            return self.user(name)
        except NotFoundError:
            pass

    def users(self, remote=False, system=False, parse=True, page_start=None,
              page_limit=None, order=None, hidden=True, inactive=True): # TODO hidden, inactive default False?
        """Return all :class:`users <User>` on server.

        :param remote: include users on remote server nodes
        :param system: include system users
        """
        if parse and getattr(self.options, 'users', None):
            for username in self.options.users:
                yield _user.User(username, self)
            return
        try:
            for name in self._companylist():
                for user in Company(name, self).users(): # TODO filter for args?
                    yield user
        except MAPIErrorNoSupport:
            for ecuser in self.sa.GetUserList(None, MAPI_UNICODE):
                user = _user.User(server=self, ecuser=ecuser)
                if ((system or user.name != u'SYSTEM') and
                    (remote or ecuser.Servername in (self.name, '')) and
                    (hidden or not user.hidden) and
                    (inactive or user.active)):
                    yield user

    def _user_query(self, query): # TODO merge as .users('..')?
        store = _store.Store(mapiobj=self.mapistore, server=self)
        restriction = _query_to_restriction(query, 'user', store)
        columns = [PR_ENTRYID, PR_DISPLAY_NAME_W, PR_SMTP_ADDRESS_W]
        table = self.gab_table(restriction=restriction, columns=columns)
        for row in table.rows():
            yield self.user(userid=_benc(row[0].value))

    def create_user(self, name, email=None, password=None, company=None, fullname=None, create_store=True):
        """Create a new :class:`user <User>` on the server.

        :param name: the login name of the user
        :param email: the email address of the user
        :param password: the login password of the user
        :param company: the company of the user
        :param fullname: the full name of the user
        :param create_store: should a store be created for the new user
        :return: :class:`<User>`
        """
        name = _unicode(name)
        fullname = _unicode(fullname or '')
        if email:
            email = _unicode(email)
        else:
            email = u'%s@%s' % (name, socket.gethostname())
        if password:
            password = _unicode(password)
        if company:
            company = _unicode(company)
        if company and company != u'Default':
            self.sa.CreateUser(ECUSER(u'%s@%s' % (name, company), password, email, fullname), MAPI_UNICODE)
            user = self.company(company).user(u'%s@%s' % (name, company))
        else:
            try:
                self.sa.CreateUser(ECUSER(name, password, email, fullname), MAPI_UNICODE)
            except MAPIErrorNoSupport:
                raise NotSupportedError("cannot create users with configured user plugin")
            except MAPIErrorCollision:
                raise DuplicateError("user '%s' already exists" % name)
            user = self.user(name)
        if create_store:
            try:
                self.sa.CreateStore(ECSTORE_TYPE_PRIVATE, _bdec(user.userid))
            except MAPIErrorCollision:
                pass # create-user userscript may already create store
        return user

    def remove_user(self, name): # XXX delete(object)?
        """Remove a user

        :param name: the login name of the user
        """
        user = self.user(name)
        self.sa.DeleteUser(user._ecuser.UserID)

    def company(self, name, create=False):
        """Return :class:`company <Company>` with given name.

        :param name: company name
        :param create: create company if it doesn't exist
        """
        try:
            return Company(name, self)
        except MAPIErrorNoSupport:
            raise NotFoundError('no such company: %s' % name)
        except NotFoundError:
            if create:
                return self.create_company(name)
            else:
                raise

    def get_company(self, name):
        """:class:`company <Company>` with given name

        :param name: the company name
        :return: :class:`company <Company>` with given name or *None* if not found.
        """
        try:
            return self.company(name)
        except NotFoundError:
            pass

    def remove_company(self, name): # XXX delete(object)?
        company = self.company(name)

        if company.name == u'Default':
            raise NotSupportedError('cannot remove company in single-tenant mode')
        else:
            self.sa.DeleteCompany(company._eccompany.CompanyID)

    def _companylist(self):
        return [eccompany.Companyname for eccompany in self.sa.GetCompanyList(MAPI_UNICODE)]

    @property
    def multitenant(self):
        """The server is multi-tenant."""
        try:
            self._companylist()
            return True
        except MAPIErrorNoSupport:
            return False

    def companies(self, remote=False, parse=True): # XXX remote?
        """Return all :class:`companies <Company>` on server.

        :param remote: include companies without users on this server node (default False)
        :param parse: take cli argument --companies into account (default True)
        :return: Generator of :class:`companies <Company>` on server.
        """
        if parse and getattr(self.options, 'companies', None):
            for name in self.options.companies:
                try:
                    yield Company(name, self)
                except MAPIErrorNoSupport:
                    raise NotFoundError('no such company: %s' % name)
            return
        try:
            for name in self._companylist():
                yield Company(name, self)
        except MAPIErrorNoSupport:
            yield Company(u'Default', self)

    def create_company(self, name):
        """Create a new :class:`company <Company>` on the server.

        :param name: the name of the company
        """
        name = _unicode(name)
        try:
            self.sa.CreateCompany(ECCOMPANY(name, None), MAPI_UNICODE)
        except MAPIErrorCollision:
            raise DuplicateError("company '%s' already exists" % name)
        except MAPIErrorNoSupport:
            raise NotSupportedError("cannot create company in single-tenant mode")
        return self.company(name)

    def _store(self, guid):
        if len(guid) != 32:
            raise Error("invalid store id: '%s'" % guid)
        try:
            storeid = _bdec(guid)
        except:
            raise Error("invalid store id: '%s'" % guid)
        table = self.ems.GetMailboxTable(None, 0) # XXX merge with Store.__init__
        table.SetColumns([PR_ENTRYID], 0)
        table.Restrict(SPropertyRestriction(RELOP_EQ, PR_STORE_RECORD_KEY, SPropValue(PR_STORE_RECORD_KEY, storeid)), TBL_BATCH)
        for row in table.QueryRows(-1, 0):
            return self._store2(row[0].Value)
        raise NotFoundError("no such store: '%s'" % guid)

    @instance_method_lru_cache(128) # backend doesn't like too many open stores
    def _store_cached(self, storeid):
        return self.mapisession.OpenMsgStore(0, storeid, IID_IMsgStore, MDB_WRITE)

    def _store2(self, storeid): # TODO max lifetime?
        if self.store_cache:
            return self._store_cached(storeid)
        else:
            return self.mapisession.OpenMsgStore(0, storeid, IID_IMsgStore, MDB_WRITE)

    def groups(self):
        """Return all :class:`groups <Group>` on server."""
        try:
            for ecgroup in self.sa.GetGroupList(None, MAPI_UNICODE):
                yield Group(ecgroup.Groupname, self)
        except NotFoundError: # XXX what to do here (single-tenant..), as groups do exist?
            pass

    def group(self, name, create=False):
        """Return :class:`group <Group>` with given name.

        :param name: group name
        :param create: create group if it doesn't exist
        """
        try:
            return Group(name, self)
        except NotFoundError:
            if create:
                return self.create_group(name)
            else:
                raise

    def create_group(self, name, fullname='', email='', hidden=False, groupid=None):
        """Create a new :class:`group <Group>` on the server.

        :param name: the name of the group
        :param fullname: the full name of the group (optional)
        :param email: the email address of the group (optional)
        :param hidden: hide the group (optional)
        :param groupid: the id of the group (optional)
        :return: :class:`group <Group>` the created group
        """
        name = _unicode(name) # XXX: fullname/email unicode?
        email = _unicode(email)
        fullname = _unicode(fullname)
        try:
            self.sa.CreateGroup(ECGROUP(name, fullname, email, int(hidden), groupid), MAPI_UNICODE)
        except MAPIErrorCollision:
            raise DuplicateError("group '%s' already exists" % name)

        return self.group(name)

    def remove_group(self, name):
        group = self.group(name)
        self.sa.DeleteGroup(group._ecgroup.GroupID)

    def delete(self, objects):
        """Delete users, groups, companies or stores from server.

        :param objects: The object(s) to delete
        """
        objects = _utils.arg_objects(objects, (_user.User, Group, Company, _store.Store), 'Server.delete')

        for item in objects:
            if isinstance(item, _user.User):
                self.remove_user(item.name)
            elif isinstance(item, Group):
                self.remove_group(item.name)
            elif isinstance(item, Company):
                self.remove_company(item.name)
            elif isinstance(item, _store.Store):
                self.remove_store(item)

    def _pubstore(self, name):
        if name == 'public':
            if not self.public_store:
                raise NotFoundError("no public store")
            return self.public_store
        else:
            company = Company(name.split('@')[1], self)
            if not company.public_store:
                raise NotFoundError("no public store for company '%s'" % company.name)
            return company.public_store

    def store(self, guid=None, entryid=None):
        """Return :class:`store <Store>` with given GUID."""
        if _is_str(guid) and _unicode(guid).split('@')[0] == 'public':
            return self._pubstore(guid)
        else:
            return _store.Store(guid=guid, entryid=entryid, server=self)

    def get_store(self, guid):
        """Return :class:`store <Store>` with given GUID or *None* if not found."""
        try:
            return self.store(guid)
        except Error:
            pass

    def stores(self, system=False, remote=False, parse=True): # XXX implement remote
        """Return all :class:`stores <Store>` on server node.

        :param system: include system stores
        :param remote: include stores on other nodes
        """
        if parse and getattr(self.options, 'stores', None):
            for guid in self.options.stores:
                if guid.split('@')[0] == 'public':
                    yield self._pubstore(guid)
                else:
                    yield _store.Store(guid, server=self)
            return

        table = self.ems.GetMailboxTable(None, 0)
        table.SetColumns([PR_DISPLAY_NAME_W, PR_ENTRYID], 0)
        for row in table.QueryRows(-1, 0):
            store = _store.Store(mapiobj=self._store2(row[1].Value), server=self)
            if not system and store.user and store.user.name == 'SYSTEM':
                continue
            yield store

    def remove_store(self, store):
        try:
            self.sa.RemoveStore(_bdec(store.guid))
        except MAPIErrorCollision:
            raise Error("cannot remove store with GUID '%s'" % store.guid)

    def sync_users(self):
        self.sa.SyncUsers(None)

    def clear_cache(self): # XXX specify one or more caches?
        self.sa.PurgeCache(PURGE_CACHE_ALL)

    def purge_softdeletes(self, days):
        self.sa.PurgeSoftDelete(days)

    def purge_deferred(self): # XXX purge all at once?
        try:
            return self.sa.PurgeDeferredUpdates() # remaining records
        except MAPIErrorNotFound:
            return 0

    def _pubhelper(self):
        try:
            self.sa.GetCompanyList(MAPI_UNICODE)
            raise Error('request for server-wide public store in multi-tenant setup')
        except MAPIErrorNoSupport:
            return next(self.companies())

    @property
    def public_store(self):
        """Public :class:`store <Store>` in single-tenant mode."""
        return self._pubhelper().public_store

    def create_public_store(self):
        """Create public :class:`store <Store>` in single-tenant mode."""
        return self._pubhelper().create_public_store()

    def hook_public_store(self, store):
        """Hook public :class:`store <Store>` in single-tenant mode.

        :param store: store to hook
        """
        return self._pubhelper().hook_public_store(store)

    def unhook_public_store(self):
        """Unhook public :class:`store <Store>` in single-tenant mode."""
        return self._pubhelper().unhook_public_store()

    @property
    def state(self):
        """Current server state."""
        return _ics.state(self.mapistore)

    def sync(self, importer, state, log=None, max_changes=None, window=None, begin=None, end=None, stats=None):
        """Perform synchronization against server node.

        :param importer: importer instance with callbacks to process changes
        :param state: start from this state (has to be given)
        :log: logger instance to receive important warnings/errors
        """
        importer.store = None
        return _ics.sync(self, self.mapistore, importer, state, max_changes, window=window, begin=begin, end=end, stats=stats)

    def sync_gab(self, importer, state=None):
        if state is None:
            state = _benc(8 * b'\0')
        return _ics.sync_gab(self, self.mapistore, importer, state)

    @_timed_cache(minutes=60)
    def _resolve_email(self, entryid=None):
        try:
            mailuser = self.mapisession.OpenEntry(entryid, None, 0)
            return self.user(HrGetOneProp(mailuser, PR_ACCOUNT_W).Value).email # XXX PR_SMTP_ADDRESS_W from mailuser?
        except (Error, MAPIErrorNotFound): # XXX deleted user
            return '' # XXX groups

    def __unicode__(self):
        return u'Server(%s)' % self.server_socket

    def __repr__(self):
        return _repr(self)
Ejemplo n.º 3
0
class Server(object):
    """Server class.

    Abstraction for Kopano servers. A MAPI session is automatically setup,
    according to the passed arguments and environment.
    """

    def __init__(self, options=None, config=None, sslkey_file=None,
            sslkey_pass=None, server_socket=None, auth_user=None,
            auth_pass=None, log=None, service=None, mapisession=None,
            parse_args=False, notifications=False, store_cache=True,
            oidc=False, _skip_check=True):
        """
        Create Server instance.

        By default, tries to connect to a storage server as configured in
        ``/etc/kopano/admin.cfg`` or at UNIX socket
        ``/var/run/kopano/server.sock``

        Looks at command-line to see if another server address or other
        related options were given (such as -c, -s, -k, -p)

        :param server_socket: similar to 'server_socket' option in config file
        :param sslkey_file: similar to 'sslkey_file' option in config file
        :param sslkey_pass: similar to 'sslkey_pass' option in config file
        :param config: path of configuration file containing common server
            options, for example ``/etc/kopano/admin.cfg``
        :param auth_user: username to user for user authentication
        :param auth_pass: password to use for user authentication
        :param options: OptionParser instance to get settings from
            (see:func:`parser`)
        :param parse_args: set this True if cli arguments should be parsed
        """
        self.options = options
        self.config = config
        self.sslkey_file = sslkey_file
        self.sslkey_pass = sslkey_pass
        self.server_socket = server_socket
        self.service = service
        if log: # TODO deprecate?
            self.log = log
        elif service:
            self.log = service.log
        elif config:
            self.log = LOG
            self.log.setLevel(_loglevel(options, config))
        else:
            self.log = LOG
        self.mapisession = mapisession
        self.store_cache = store_cache

        if not _skip_check:
            warnings.warn('use kopano.server instead of kopano.Server',
                _DeprecationWarning)

        if not self.mapisession:
            # get cmd-line options
            if not self.options:
                if parse_args:
                    self.options, _ = parser().parse_args()
                else:
                    self.options = Options()

            # determine config file
            if config:
                pass
            elif getattr(self.options, 'config_file', None):
                config_file = os.path.abspath(self.options.config_file)
                config = _config.Config(
                    None, filename=self.options.config_file)
            else:
                config_file = '/etc/kopano/admin.cfg'
                try:
                    open(config_file) # check if accessible
                    config = _config.Config(None, filename=config_file)
                except IOError:
                    pass
            self.config = config

            # get defaults
            if os.getenv("KOPANO_SOCKET"): # env variable used in testset
                self.server_socket = os.getenv("KOPANO_SOCKET")
            elif config:
                if (not (server_socket or \
                    getattr(self.options, 'server_socket', None))):
                    self.server_socket = config.get('server_socket')
                    self.sslkey_file = config.get('sslkey_file')
                    self.sslkey_pass = config.get('sslkey_pass')
            self.server_socket = self.server_socket or "default:"

            # override with explicit or command-line args
            self.server_socket = server_socket or \
                getattr(self.options, 'server_socket', None) or \
                self.server_socket
            self.sslkey_file = sslkey_file or \
                getattr(self.options, 'sslkey_file', None) or \
                self.sslkey_file
            self.sslkey_pass = sslkey_pass or \
                getattr(self.options, 'sslkey_pass', None) or \
                self.sslkey_pass

            # make actual connection. in case of service,
            # wait until this succeeds.
            self.auth_user = auth_user or \
                getattr(self.options, 'auth_user', None) or \
                ''
            self.auth_pass = auth_pass or \
                getattr(self.options, 'auth_pass', None) or \
                ''

            flags = 0
            self.notifications = notifications
            if not notifications:
                flags |= EC_PROFILE_FLAGS_NO_NOTIFICATIONS

            # Username and password was supplied, so let us do verification
            # (OpenECSession will not check password unless this parameter
            # is provided)
            if self.auth_user and self.auth_pass:
                flags |= EC_PROFILE_FLAGS_NO_UID_AUTH

            if oidc:
                flags |= EC_PROFILE_FLAGS_OIDC
            elif not self.auth_user:
                self.auth_user = "******"

            while True:
                try:
                    self.mapisession = OpenECSession(
                        self.auth_user,
                        self.auth_pass,
                        self.server_socket,
                        sslkey_file=self.sslkey_file,
                        sslkey_pass=self.sslkey_pass,
                        flags=flags)
                    break
                except (MAPIErrorNetworkError, MAPIErrorDiskError):
                    if service:
                        self.log.warning("could not connect to server at \
'%s', retrying in 5 sec", self.server_socket)
                        time.sleep(5)
                    else:
                        raise Error("could not connect to server at '%s'" %
                            self.server_socket)
                except MAPIErrorLogonFailed:
                    raise LogonError('Could not logon to server: username or \
password incorrect')

        self._mapistore = None
        self._sa = None
        self._ems = None
        self._ab = None
        self._admin_store = None
        self._gab = None
        self._pseudo_url = None
        self._name = None

    @property
    def mapistore(self):
        if self._mapistore is None:
            self._mapistore = GetDefaultStore(self.mapisession)
        return self._mapistore

    @property
    def sa(self):
        if self._sa is None:
            self._sa = self.mapistore.QueryInterface(IID_IECServiceAdmin)
        return self._sa

    @property
    def ems(self):
        if self._ems is None:
            self._ems = self.mapistore.QueryInterface(IID_IExchangeManageStore)
        return self._ems

    @property
    def pseudo_url(self):
        if self._pseudo_url is None:
            entryid = HrGetOneProp(self.mapistore, PR_STORE_ENTRYID).Value
            # TODO ECSERVER
            self._pseudo_url = entryid[entryid.find(b'pseudo:'):-1]
        return self._pseudo_url

    @property
    def name(self):
        """Server name."""
        if self._name is None:
            # TODO encoding, get this kind of stuff from
            # pr_ec_statstable_servers..?
            self._name = self.pseudo_url[9:].decode('ascii')
        return self._name

    # TODO delay mapi sessions until actually needed
    def nodes(self):
        """For a multi-server setup, return all servers (nodes)."""
        for row in self.table(PR_EC_STATSTABLE_SERVERS).dict_rows():
            yield Server(options=self.options, config=self.config,
                sslkey_file=self.sslkey_file, sslkey_pass=self.sslkey_pass,
                server_socket=codecs.decode(
                    row[PR_EC_STATS_SERVER_HTTPSURL], 'utf-8'),
                log=self.log, service=self.service, _skip_check=True)

    def table(self, name, restriction=None, order=None, columns=None):
        return Table(
            self,
            self.mapistore,
            self.mapistore.OpenProperty(name, IID_IMAPITable, MAPI_UNICODE, 0),
            name,
            restriction=restriction,
            order=order,
            columns=columns,
        )

    def tables(self):
        for table in (
            PR_EC_STATSTABLE_SYSTEM,
            PR_EC_STATSTABLE_SESSIONS,
            PR_EC_STATSTABLE_USERS,
            PR_EC_STATSTABLE_COMPANY,
            PR_EC_STATSTABLE_SERVERS):
            try:
                yield self.table(table)
            except MAPIErrorNotFound:
                pass

    @property
    def ab(self):
        if not self._ab:
            self._ab = self.mapisession.OpenAddressBook(0, None, 0) # TODO
        return self._ab

    @property
    def admin_store(self):
        """Admin store."""
        if not self._admin_store:
            self._admin_store = _store.Store(
                mapiobj=self.mapistore, server=self)
        return self._admin_store

    @property
    def gab(self):
        # TODO deprecate, because MAPI-level
        if not self._gab:
            self._gab = self.ab.OpenEntry(self.ab.GetDefaultDir(), None, 0)
        return self._gab

    @property
    def guid(self):
        """Server GUID."""
        return _benc(HrGetOneProp(self.mapistore, PR_MAPPING_SIGNATURE).Value)

    def user(self, name=None, email=None, create=False, userid=None):
        """:class:`User <User>` with given name, email address or userid.

        :param name: User name (optional)
        :param email: User email address (optional)
        :param userid: User userid (optional)
        :param create: create user if it doesn't exist (default False,
            name required)
        """

        if not (name or email or userid):
            raise ArgumentError('missing argument to identify user')

        try:
            return _user.User(name, email=email, server=self, userid=userid)
        except NotFoundError:
            if create and name:
                return self.create_user(name)
            else:
                raise

    def get_user(self, name):
        """Return :class:`user <User>` with given name or *None* if not
        found."""
        try:
            return self.user(name)
        except NotFoundError:
            pass

    # TODO change default for hidden
    def users(self, remote=False, system=False, parse=True, page_start=None,
              page_limit=None, order=None, hidden=True, inactive=True,
              _server=None, _company=None, query=None):
        """Return all :class:`users <User>` on server.

        :param remote: Include users on remote server nodes (default False)
        :param system: Include system users (default False)
        :param hidden: Include hidden users (default True)
        :param inactive: Include inactive users (default True)
        :param query: Search query (optional)
        """

        # users specified on command-line
        if parse and getattr(self.options, 'users', None):
            for username in self.options.users:
                yield _user.User(username, self)
            return

        # global listing on multitenant setup
        if self.multitenant and not _company:
            if (page_limit is None and page_start is None and \
                query is None and order is None):
                for company in self.companies():
                    for user in company.users(remote=remote, system=system,
                            parse=parse, hidden=hidden, inactive=inactive):
                        yield user
                return
            else:
                raise NotSupportedError('unsupported method of user listing')

        # find right addressbook container (global or for company)
        gab = self.gab

        restriction = SAndRestriction([
            SPropertyRestriction(RELOP_EQ, PR_OBJECT_TYPE,
                SPropValue(PR_OBJECT_TYPE, MAPI_MAILUSER)),
            SPropertyRestriction(RELOP_NE, PR_DISPLAY_TYPE,
                SPropValue(PR_DISPLAY_TYPE, DT_REMOTE_MAILUSER))
        ])

        if _company and _company.name != 'Default':
            htable = gab.GetHierarchyTable(0)
            htable.SetColumns([PR_ENTRYID], TBL_BATCH)
            # TODO(longsleep): Find a way to avoid finding the limited table,
            # if the gab is itself already limited to the same.
            try:
                htable.FindRow(SContentRestriction(
                        FL_FULLSTRING | FL_IGNORECASE,
                        PR_DISPLAY_NAME_W,
                        SPropValue(PR_DISPLAY_NAME_W, _company.name)),
                    BOOKMARK_BEGINNING, 0)
            except MAPIErrorNotFound:
                # If not, found we do not have permission to access that row. and
                # instead fall back to the gab and let it handle access and
                # limits based on user authentication.
                container = gab
            else:
                row = htable.QueryRows(1, 0)[0]
                container = gab.OpenEntry(row[0].Value, None, 0)
        else:
            container = gab

        table = container.GetContentsTable(0)
        table.SetColumns([PR_ENTRYID], MAPI_UNICODE)

        # apply query restriction
        if query is not None:
            store = _store.Store(mapiobj=self.mapistore, server=self)
            restriction.lpRes.append(
                _query_to_restriction(query, 'user', store).mapiobj)

        table.Restrict(restriction, TBL_BATCH)

        # TODO apply order argument here

        def include(user, ecuser):
            return ((system or user.name != 'SYSTEM') and
                    (remote or ecuser.Servername in (self.name, '')) and
                    (hidden or not user.hidden) and
                    (inactive or user.active))

        # TODO simpler/faster if sa.GetUserList could do restrictions,
        # ordering, pagination..
        # since then we can always work with ecuser objects in bulk
        userid_ecuser = None
        if page_limit is None and page_start is None and query is None:
            userid_ecuser = {}
            if _company and _company.name != 'Default':
                ecusers = self.sa.GetUserList(
                    _company._eccompany.CompanyID, MAPI_UNICODE)
            else:
                ecusers = self.sa.GetUserList(None, MAPI_UNICODE)
            for ecuser in ecusers:
                userid_ecuser[ecuser.UserID] = ecuser

                # fast path: just get all users
                if order is None:
                    user = _user.User(server=self, ecuser=ecuser)
                    if include(user, ecuser):
                        yield user
            if order is None:
                return

        # loop over rows and paginate
        pos = 0
        count = 0

        while True:
            rows = table.QueryRows(50, 0)
            if not rows:
                break
            for row in rows:
                userid = row[0].Value
                if userid_ecuser is None:
                    try:
                        user = _user.User(server=self, userid=_benc(userid))
                    except NotFoundError:
                        continue
                else:
                    try:
                        user = _user.User(
                            server=self, ecuser=userid_ecuser[userid])
                    except KeyError:
                        continue

                if include(user, user._ecuser):
                    if page_start is None or pos >= page_start:
                        yield user
                        count += 1
                    if page_limit is not None and count >= page_limit:
                        return
                    pos += 1

    def create_user(self, name, email=None, password=None, company=None,
            fullname=None, create_store=True):
        """Create a new :class:`user <User>` on the server.

        :param name: User login name
        :param email: User email address (optional)
        :param password: User login password (optional)
        :param company: User company (optional)
        :param fullname: User full name (optional)
        :param create_store: Should a store be created (default True)
        """
        fullname = str(fullname or name or '')
        name = str(name)
        if email:
            email = str(email)
        else:
            email = '%s@%s' % (name, socket.gethostname())
        if password:
            password = str(password)
        if company:
            company = str(company)
        if company and company != 'Default':
            self.sa.CreateUser(ECUSER('%s@%s' % (name, company), password,
                email, fullname), MAPI_UNICODE)
            user = self.company(company).user('%s@%s' % (name, company))
        else:
            try:
                self.sa.CreateUser(
                    ECUSER(name, password, email, fullname),MAPI_UNICODE)
            except MAPIErrorNoSupport:
                raise NotSupportedError(
                    "cannot create users with configured user plugin")
            except MAPIErrorCollision:
                raise DuplicateError("user '%s' already exists" % name)
            user = self.user(name)
        if create_store:
            try:
                self.sa.CreateStore(ECSTORE_TYPE_PRIVATE, _bdec(user.userid))
            except MAPIErrorCollision:
                pass # create-user userscript may already create store
        return user

    def remove_user(self, name): # TODO delete(object)?
        """Remove :class:`User`.

        :param name: User login name
        """
        user = self.user(name)
        self.sa.DeleteUser(user._ecuser.UserID)

    def company(self, name, create=False):
        """Return :class:`company <Company>` with given name.

        :param name: Company name
        :param create: Create company if it doesn't exist (default False)
        """
        try:
            return Company(name, self)
        except MAPIErrorNoSupport:
            raise NotFoundError('no such company: %s' % name)
        except NotFoundError:
            if create:
                return self.create_company(name)
            else:
                raise

    def get_company(self, name):
        """:class:`Company <Company>` with given name or *None* if not found.

        :param name: Company name
        """
        try:
            return self.company(name)
        except NotFoundError:
            pass

    def remove_company(self, name): # TODO delete(object)?
        """Remove :class:`Company`.

        :param name: Company name
        """
        company = self.company(name)

        if company.name == 'Default':
            raise NotSupportedError(
                'cannot remove company in single-tenant mode')
        else:
            self.sa.DeleteCompany(company._eccompany.CompanyID)

    def _companylist(self):
        return [eccompany.Companyname for eccompany in \
            self.sa.GetCompanyList(MAPI_UNICODE)]

    @property
    def multitenant(self):
        """The server is multi-tenant (multiple :class:`companies <Company>`)."""
        try:
            self._companylist()
            return True
        except MAPIErrorNoSupport:
            return False

    def companies(self, remote=False, parse=True): # TODO remote?
        """Return all :class:`companies <Company>` on server.

        :param remote: include companies without users on this server node
            (default False)
        :param parse: take cli argument --companies into account
            (default True)
        """
        if parse and getattr(self.options, 'companies', None):
            for name in self.options.companies:
                try:
                    yield Company(name, self)
                except MAPIErrorNoSupport:
                    raise NotFoundError('no such company: %s' % name)
            return
        try:
            for name in self._companylist():
                yield Company(name, self)
        except MAPIErrorNoSupport:
            yield Company('Default', self)

    def create_company(self, name):
        """Create a new :class:`company <Company>` on the server.

        :param name: Company name
        """
        name = str(name)
        try:
            self.sa.CreateCompany(ECCOMPANY(name, None), MAPI_UNICODE)
        except MAPIErrorCollision:
            raise DuplicateError("company '%s' already exists" % name)
        except MAPIErrorNoSupport:
            raise NotSupportedError(
                'cannot create company in single-tenant mode')
        return self.company(name)

    # TODO move guid checks to caller
    def _store(self, guid):
        if len(guid) != 32:
            raise Error("invalid store id: '%s'" % guid)
        try:
            storeid = _bdec(guid)
        except:
            raise Error("invalid store id: '%s'" % guid)
        # TODO merge with Store.__init__
        table = self.ems.GetMailboxTable(None, 0)
        table.SetColumns([PR_ENTRYID], 0)
        table.Restrict(SPropertyRestriction(RELOP_EQ, PR_STORE_RECORD_KEY,
            SPropValue(PR_STORE_RECORD_KEY, storeid)), TBL_BATCH)
        for row in table.QueryRows(2147483647, 0):
            return self._store2(row[0].Value)
        raise NotFoundError("no such store: '%s'" % guid)

    @instance_method_lru_cache(128) # backend doesn't like too many open stores
    def _store_cached(self, storeid):
        return self.mapisession.OpenMsgStore(
            0, storeid, IID_IMsgStore, MDB_WRITE)

    def _store2(self, storeid): # TODO max lifetime?
        if self.store_cache:
            return self._store_cached(storeid)
        else:
            return self.mapisession.OpenMsgStore(
                0, storeid, IID_IMsgStore, MDB_WRITE)

    def groups(self):
        """Return all :class:`groups <Group>` on the server."""
        try:
            for ecgroup in self.sa.GetGroupList(None, MAPI_UNICODE):
                yield Group(ecgroup.Groupname, self)
        except NotFoundError:
            # TODO what to do here (single-tenant..), as groups do exist?
            pass

    def group(self, name, create=False):
        """Return :class:`group <Group>` with given name.

        :param name: Group name
        :param create: Create group if it doesn't exist (default False)
        """
        try:
            return Group(name, self)
        except NotFoundError:
            if create:
                return self.create_group(name)
            else:
                raise

    def create_group(self, name, fullname='', email='', hidden=False,
            groupid=None):
        """Create a new :class:`group <Group>` on the server.

        :param name: the name of the group
        :param fullname: the full name of the group (optional)
        :param email: the email address of the group (optional)
        :param hidden: hide the group (optional)
        :param groupid: the id of the group (optional)
        """
        name = str(name) # TODO: fullname/email unicode?
        email = str(email)
        fullname = str(fullname)
        try:
            self.sa.CreateGroup(ECGROUP(name, fullname, email, int(hidden),
                groupid), MAPI_UNICODE)
        except MAPIErrorNoSupport:
            raise NotSupportedError(
                'cannot create groups with configured user plugin')
        except MAPIErrorCollision:
            raise DuplicateError("group '%s' already exists" % name)

        return self.group(name)

    def remove_group(self, name): # TODO delete
        """Remove :class:`group <Group>`.

        :param name: Group name
        """
        group = self.group(name)
        self.sa.DeleteGroup(group._ecgroup.GroupID)

    def delete(self, objects):
        """Delete users, groups, companies or stores from server.

        :param objects: The object(s) to delete
        """
        objects = _utils.arg_objects(objects,
            (_user.User, Group, Company, _store.Store), 'Server.delete')

        for item in objects:
            if isinstance(item, _user.User):
                self.remove_user(item.name)
            elif isinstance(item, Group):
                self.remove_group(item.name)
            elif isinstance(item, Company):
                self.remove_company(item.name)
            elif isinstance(item, _store.Store):
                self.remove_store(item)

    def _pubstore(self, name):
        if name == 'public':
            if not self.public_store:
                raise NotFoundError("no public store")
            return self.public_store
        else:
            company = Company(name.split('@')[1], self)
            if not company.public_store:
                raise NotFoundError(
                    "no public store for company '%s'" % company.name)
            return company.public_store

    def store(self, guid=None, entryid=None):
        """Return :class:`store <Store>` with given GUID or entryid.

        :param guid: Store GUID (optional)
        :param entryid: Store entryid (optional)
        """
        if isinstance(guid, str) and str(guid).split('@')[0] == 'public':
            return self._pubstore(guid)
        else:
            return _store.Store(guid=guid, entryid=entryid, server=self)

    def get_store(self, guid):
        """Return :class:`store <Store>` with given GUID or
        *None* if not found."""
        try:
            return self.store(guid)
        except Error:
            pass

    # TODO implement remote
    def stores(self, system=False, remote=False, parse=True):
        """Return all :class:`stores <Store>` on the server node.

        :param system: Include system stores (default False)
        :param remote: Include stores on other nodes (default False)
        """
        if parse and getattr(self.options, 'stores', None):
            for guid in self.options.stores:
                if guid.split('@')[0] == 'public':
                    yield self._pubstore(guid)
                elif len(guid) == 32:
                    yield _store.Store(guid=guid, server=self)
                else:
                    yield _store.Store(entryid=guid, server=self)
            return

        table = self.ems.GetMailboxTable(None, 0)
        table.SetColumns([PR_DISPLAY_NAME_W, PR_ENTRYID], 0)
        for row in table.QueryRows(2147483647, 0):
            store = _store.Store(
                mapiobj=self._store2(row[1].Value),server=self)
            if not system and store.user and store.user.name == 'SYSTEM':
                continue
            yield store

    def _get_server_port(self):
        '''Parse server_socket and return None or the server port used'''

        if self.server_socket == 'default:':
            return

        if self.server_socket.startswith('file://'):
            return

        parsed = urlparse(self.server_socket)
        if not parsed:
            return
        return parsed.port

    def create_store(self, user, _msr=False):
        """Create store for :class:`User`.

        :param user: User
        """
        # TODO detect homeserver override
        storetype = ECSTORE_TYPE_PRIVATE
        # TODO configurable storetype

        if _msr:
            try:
                storeid, rootid = self.sa.CreateEmptyStore(storetype,
                    _bdec(user.userid), EC_OVERRIDE_HOMESERVER, None, None)
            except MAPIErrorCollision:
                raise DuplicateError("user '%s' already has an associated \
store (unhook first?)" % user.name)
            # Parse the server, fallback to 237 for now
            server_port = self._get_server_port()
            if not server_port:
                # TODO: raise exception?
                server_port = 237

            store_entryid = \
                WrapStoreEntryID(0, b'zarafa6client.dll',storeid[:-4]) + \
                b'https://' + \
                codecs.encode(self.name, 'utf-8') + \
                b':' + str(server_port).encode('utf-8') + b'\x00'
            # multi-server flag
            store_entryid = \
                store_entryid[:66] + \
                b'\x10' + \
                store_entryid[67:]
            store = self.store(entryid=_benc(store_entryid))

            # TODO add EC_OVERRIDE_HOMESERVER flag to CreateStore instead?
            # or how does the old MSR do this?

            # TODO language

            # system folders
            root = store.root

            store.findroot = root.create_folder('FINDER_ROOT')
            store.findroot.permission(
                self.group('Everyone'), create=True).rights = \
                ['read_items', 'create_subfolders', 'edit_own', 'delete_own',
                 'folder_visible']

            store.views = root.create_folder('IPM_VIEWS')
            store.common_views = root.create_folder('IPM_COMMON_VIEWS')

            freebusydata = root.create_folder('Freebusy Data')
            root.create_folder('Schedule')
            root.create_folder('Shortcut')

            # special folders
            subtree = store.subtree = root.folder('IPM_SUBTREE', create=True)

            calendar = subtree.create_folder('Calendar')
            store.calendar = calendar
            store.contacts = subtree.create_folder('Contacts')
            # TODO Conversation Action Settings?
            store.wastebasket = subtree.create_folder('Deleted Items')
            store.drafts = subtree.create_folder('Drafts')
            store.inbox = subtree.create_folder('Inbox')
            store.journal = subtree.create_folder('Journal')
            store.junk = subtree.create_folder('Junk E-mail')
            store.notes = subtree.create_folder('Notes')
            store.outbox = subtree.create_folder('Outbox')
            # TODO Quick Step Settings?
            # TODO RSS Feeds?
            store.sentmail = subtree.create_folder('Sent Items')
            # TODO Suggested Contacts?
            store.tasks = subtree.create_folder('Tasks')

            # freebusy message TODO create dynamically instead?
            fbmsg = root.create_item(
                subject='LocalFreebusy',
                message_class='IPM.Microsoft.ScheduleData.FreeBusy'
            )

            calendar_fbmsg = calendar.create_item(
                subject='LocalFreebusy',
                message_class='IPM.Microsoft.ScheduleData.FreeBusy',
                associated=True
            )

            # PR_FREEBUSY_ENTRYIDS[0] gives associated freebusy iten in calendar
            # PR_FREEBUSY_ENTRYIDS[1] Localfreebusy (used for delegate properties) message
            # PR_FREEBUSY_ENTRYIDS[2] global Freebusydata in public store (empty in Kopano)
            # PR_FREEBUSY_ENTRYIDS[3] Freebusydata in IPM_SUBTREE
            root[PR_FREEBUSY_ENTRYIDS] = [_bdec(calendar_fbmsg.entryid), _bdec(fbmsg.entryid), b'', _bdec(freebusydata.entryid)]

        else:
            store = user.create_store()
        return store

    def remove_store(self, store):
        """Remove :class:`Store`.

        :param store: Store
        """
        try:
            self.sa.RemoveStore(_bdec(store.guid))
        except MAPIErrorCollision:
            raise Error("cannot remove store with GUID '%s'" % store.guid)

    def sync_users(self):
        """Synchronize users with external source."""
        self.sa.SyncUsers(None)

    def clear_cache(self): # TODO specify one or more caches?
        """Clear caches."""
        self.sa.PurgeCache(PURGE_CACHE_ALL)

    def purge_softdeletes(self, days):
        """Purge soft-deletes older than certain amount of days.

        :param days: Amount of days in the past.
        """
        self.sa.PurgeSoftDelete(days)

    def purge_deferred(self): # TODO purge all at once?
        """Purge deferred updates."""
        try:
            return self.sa.PurgeDeferredUpdates() # remaining records
        except MAPIErrorNotFound:
            return 0

    def _pubhelper(self):
        try:
            self.sa.GetCompanyList(MAPI_UNICODE)
            raise NotSupportedError('request for server-wide public store in \
multi-tenant setup')
        except MAPIErrorNoSupport:
            return next(self.companies())

    @property
    def public_store(self):
        """Public :class:`store <Store>` (single-tenant mode)."""
        return self._pubhelper().public_store

    def create_public_store(self):
        """Create public :class:`store <Store>` (single-tenant mode)."""
        return self._pubhelper().create_public_store()

    def hook_public_store(self, store):
        """Hook public :class:`store <Store>` (single-tenant mode).

        :param store: store to hook
        """
        return self._pubhelper().hook_public_store(store)

    def unhook_public_store(self):
        """Unhook public :class:`store <Store>` (single-tenant mode)."""
        return self._pubhelper().unhook_public_store()

    @property
    def state(self):
        """Server state (for use with ICS synchronization)."""
        return _ics.state(self.mapistore)

    def sync(self, importer, state, log=None, max_changes=None, window=None,
            begin=None, end=None, stats=None):
        """Perform ICS synchronization against server node.

        :param importer: importer instance with callbacks to process changes
        :param state: start from this state (has to be given)
        :log: logger instance to receive important warnings/errors
        """
        importer.store = None
        return _ics.sync(self, self.mapistore, importer, state, max_changes,
            window=window, begin=begin, end=end, stats=stats)

    def sync_gab(self, importer, state=None):
        """Perform ICS synchronization against global address book.

        :param importer: importer instance with callbacks to process changes
        :param state: start from this state (optional)
        """
        if state is None:
            state = _benc(8 * b'\0')
        return _ics.sync_gab(self, self.mapistore, importer, state)

    def stats(self):
        """Dictionary containing useful server statistics."""
        table = self.table(PR_EC_STATSTABLE_SYSTEM)
        stats = {}
        # TODO use *_W?
        for key, value in table.dict_(
            PR_DISPLAY_NAME, PR_EC_STATS_SYSTEM_VALUE).items():
            stats[key] = value

        # TODO shouldn't be necessary
        stats = dict([(s.decode('UTF-8'), stats[s].decode('UTF-8')) \
            for s in stats])

        return stats

    def stat(self, key): # TODO optimize with restriction?
        """Specific server statistic.

        :param key: Statistic key
        """
        return self.stats()[key]

    def get_stat(self, key, default=None):
        """Specific server statistic or *None* if not found.

        :param key: Statistic key
        """
        return self.stats().get(key, default)

    @_timed_cache(minutes=60)
    def _resolve_email(self, entryid=None):
        try:
            mailuser = self.mapisession.OpenEntry(entryid, None, 0)
            # TODO PR_SMTP_ADDRESS_W from mailuser?
            return self.user(HrGetOneProp(mailuser, PR_ACCOUNT_W).Value).email
        except (Error, MAPIErrorNotFound): # TODO deleted user
            return '' # TODO groups

    def __unicode__(self):
        return 'Server(%s)' % self.server_socket

    def __repr__(self):
        return self.__unicode__()