diff --git a/pykolab/auth/__init__.py b/pykolab/auth/__init__.py index 84014ae..531de1e 100644 --- a/pykolab/auth/__init__.py +++ b/pykolab/auth/__init__.py @@ -1,319 +1,319 @@ # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import logging import os import time import pykolab import pykolab.base from pykolab.translate import _ log = pykolab.getLogger('pykolab.auth') conf = pykolab.getConf() class Auth(pykolab.base.Base): """ This is the Authentication and Authorization module for PyKolab. """ def __init__(self, domain=None): """ Initialize the authentication class. """ pykolab.base.Base.__init__(self, domain=domain) self._auth = None def authenticate(self, login): """ Verify login credentials supplied in login against the appropriate authentication backend. Login is a simple list of username, password, service and, optionally, the realm. """ if len(login) == 3: # The realm has not been specified. See if we know whether or not # to use virtual_domains, as this may be a cause for the realm not # having been specified separately. use_virtual_domains = conf.get('imap', 'virtual_domains') # TODO: Insert debug statements #if use_virtual_domains == "userid": #print "# Derive domain from login[0]" #elif not use_virtual_domains: #print "# Explicitly do not user virtual domains??" #else: ## Do use virtual domains, derive domain from login[0] #print "# Derive domain from login[0]" if len(login[0].split('@')) > 1: domain = login[0].split('@')[1] elif len(login) >= 4: domain = login[3] else: domain = conf.get("kolab", "primary_domain") # realm overrides domain if len(login) == 4: domain = login[3] retval = self._auth.authenticate(login, domain) return retval def connect(self, domain=None): """ Connect to the domain authentication backend using domain, or fall back to the primary domain specified by the configuration. """ - log.debug(_("Called for domain %r") % (domain), level=9) + log.debug(_("Called for domain %r") % (domain), level=8) if not self._auth == None: return if domain == None: if not self.domain == None: section = self.domain domain = self.domain else: section = 'kolab' domain = conf.get('kolab', 'primary_domain') else: self.list_domains(domain) section = domain log.debug( _("Using section %s and domain %s") % (section,domain), - level=9 + level=8 ) if not self.domains == None and self.domains.has_key(domain): section = self.domains[domain] domain = self.domains[domain] log.debug( _("Using section %s and domain %s") % (section,domain), - level=9 + level=8 ) log.debug( _("Connecting to Authentication backend for domain %s") % ( domain ), level=8 ) if not conf.has_section(section): section = 'kolab' if not conf.has_option(section, 'auth_mechanism'): log.debug( _("Section %s has no option 'auth_mechanism'") % (section), - level=9 + level=8 ) section = 'kolab' else: log.debug( _("Section %s has auth_mechanism: %r") % ( section, conf.get(section,'auth_mechanism') ), - level=9 + level=8 ) # Get the actual authentication and authorization backend. if conf.get(section, 'auth_mechanism') == 'ldap': - log.debug(_("Starting LDAP..."), level=9) + log.debug(_("Starting LDAP..."), level=8) from pykolab.auth import ldap self._auth = ldap.LDAP(self.domain) elif conf.get(section, 'auth_mechanism') == 'sql': from pykolab.auth import sql self._auth = sql.SQL(self.domain) else: - log.debug(_("Starting LDAP..."), level=9) + log.debug(_("Starting LDAP..."), level=8) from pykolab.auth import ldap self._auth = ldap.LDAP(self.domain) self._auth.connect() def disconnect(self, domain=None): """ Connect to the domain authentication backend using domain, or fall back to the primary domain specified by the configuration. """ if domain == None: section = 'kolab' domain = conf.get('kolab', 'primary_domain') else: section = domain if not self._auth or self._auth == None: return self._auth._disconnect() del self._auth self._auth = None def find_folder_resource(self, folder): """ Find one or more resources corresponding to the shared folder name. """ if not self._auth or self._auth == None: self.connect() result = self._auth.find_folder_resource(folder) if isinstance(result, list) and len(result) == 1: return result[0] else: return result def find_recipient(self, address, domain=None, search_attrs=None): """ Find one or more entries corresponding to the recipient address. """ if not domain == None and not self.domain == domain: self.connect(domain=domain) if not self._auth or self._auth == None: self.connect(domain=domain) result = self._auth.find_recipient(address, search_attrs=search_attrs) if isinstance(result, list) and len(result) == 1: return result[0] else: return result def find_resource(self, address): """ Find one or more resources corresponding to the recipient address. """ if not self._auth or self._auth == None: self.connect() result = self._auth.find_resource(address) if isinstance(result, list) and len(result) == 1: return result[0] else: return result def find_user(self, attr, value, **kw): return self._auth.search_entry_by_attribute(attr, value, **kw) def find_user_dn(self, login, kolabuser=False): return self._auth._find_user_dn(login, kolabuser); def list_recipient_addresses(self, user): return self._auth.list_recipient_addresses(user) def extract_recipient_addresses(self, entry): return self._auth.extract_recipient_addresses(entry) def list_delegators(self, user): return self._auth.list_delegators(user) def list_domains(self, domain=None): """ List the domains using the auth_mechanism setting in the kolab section of the configuration file, either ldap or sql or (...). The actual setting would be used by self.connect(), and stuffed into self._auth, for use with self._auth._list_domains() For each domain found, returns a two-part tuple of the primary domain and a list of secondary domains (aliases). """ # Connect to the global namespace self.connect() # Find the domains in the authentication backend. kolab_primary_domain = conf.get('kolab', 'primary_domain') if self.domains == None: try: domains = self._auth._list_domains(domain) except: if not self.domain == kolab_primary_domain: return { self.domain: self.domain } else: domains = {} # If no domains are found, the primary domain is used. if len(domains) < 1: self.domains = { kolab_primary_domain: kolab_primary_domain } else: self.domains = {} for primary, secondaries in domains: self.domains[primary.lower()] = primary.lower() for secondary in secondaries: self.domains[secondary.lower()] = primary.lower() return self.domains def synchronize(self, mode=0, callback=None): self._auth.synchronize(mode=mode, callback=callback) def domain_default_quota(self, domain): return self._auth._domain_default_quota(domain) def domain_naming_context(self, domain): return self._auth._domain_naming_context(domain) def primary_domain_for_naming_context(self, domain): return self._auth._primary_domain_for_naming_context(domain) def get_entry_attribute(self, domain, entry, attribute): return self._auth.get_entry_attribute(entry, attribute) def get_entry_attributes(self, domain, entry, attributes): return self._auth.get_entry_attributes(entry, attributes) def get_user_attribute(self, domain, user, attribute): return self._auth.get_entry_attribute(user, attribute) def get_user_attributes(self, domain, user, attributes): return self._auth.get_entry_attributes(user, attributes) def search_entry_by_attribute(self, attr, value, **kw): return self._auth.search_entry_by_attribute(attr, value, **kw) def search_mail_address(self, domain, mail_address): return self._auth._search_mail_address(domain, mail_address) def set_entry_attribute(self, domain, entry, attribute, value): return self._auth.set_entry_attribute(entry, attribute, value) def set_entry_attributes(self, domain, entry, attributes): return self._auth.set_entry_attributes(entry, attributes) def set_user_attribute(self, domain, user, attribute, value): self._auth._set_user_attribute(user, attribute, value) diff --git a/pykolab/auth/ldap/__init__.py b/pykolab/auth/ldap/__init__.py index 5efa9dc..0c440da 100644 --- a/pykolab/auth/ldap/__init__.py +++ b/pykolab/auth/ldap/__init__.py @@ -1,3155 +1,3157 @@ # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import datetime import _ldap import ldap import ldap.async import ldap.controls import ldap.filter import logging import time import traceback import pykolab import pykolab.base from pykolab import utils from pykolab.constants import * from pykolab.errors import * from pykolab.translate import _ log = pykolab.getLogger('pykolab.auth') conf = pykolab.getConf() import auth_cache import cache # Catch python-ldap-2.4 changes from distutils import version if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): LDAP_CONTROL_PAGED_RESULTS = ldap.CONTROL_PAGEDRESULTS else: LDAP_CONTROL_PAGED_RESULTS = ldap.LDAP_CONTROL_PAGE_OID try: from ldap.controls import psearch except: log.warning(_("Python LDAP library does not support persistent search")) class SimplePagedResultsControl(ldap.controls.SimplePagedResultsControl): """ Python LDAP 2.4 and later breaks the API. This is an abstraction class so that we can handle either. """ def __init__(self, page_size=0, cookie=''): if version.StrictVersion( '2.4.0' ) <= version.StrictVersion( ldap.__version__ ): ldap.controls.SimplePagedResultsControl.__init__( self, size=page_size, cookie=cookie ) else: ldap.controls.SimplePagedResultsControl.__init__( self, LDAP_CONTROL_PAGED_RESULTS, True, (page_size, '') ) def cookie(self): if version.StrictVersion( '2.4.0' ) <= version.StrictVersion( ldap.__version__ ): return self.cookie else: return self.controlValue[1] def size(self): if version.StrictVersion( '2.4.0' ) <= version.StrictVersion( ldap.__version__ ): return self.size else: return self.controlValue[0] class LDAP(pykolab.base.Base): """ Abstraction layer for the LDAP authentication / authorization backend, for use with Kolab. """ def __init__(self, domain=None): """ Initialize the LDAP object for domain. If no domain is specified, domain name space configured as 'kolab'.'primary_domain' is used. """ pykolab.base.Base.__init__(self, domain=domain) self.ldap = None self.ldap_priv = None self.bind = None if domain == None: self.domain = conf.get('kolab', 'primary_domain') else: self.domain = domain def authenticate(self, login, realm): """ Find the entry corresponding to login, and attempt a bind. login is a tuple with 4 values. In order of appearance; [0] - the login username. [1] - the password [2] - the service (optional) [3] - the realm Called from pykolab.auth.Auth, the realm parameter is derived, while login[3] preserves the originally specified realm. """ try: log.debug( _("Attempting to authenticate user %s in realm %s") % ( login[0], realm ), level=8 ) except: pass self.connect(immediate=True) self._bind() # See if we know a base_dn for the domain base_dn = None try: base_dn = auth_cache.get_entry(self.domain) except Exception, errmsg: log.error(_("Authentication cache failed: %r") % (errmsg)) pass if base_dn == None: config_base_dn = self.config_get('base_dn') ldap_base_dn = self._kolab_domain_root_dn(self.domain) if not ldap_base_dn == None and not ldap_base_dn == config_base_dn: base_dn = ldap_base_dn else: base_dn = config_base_dn try: auth_cache.set_entry(self.domain, base_dn) except Exception, errmsg: log.error(_("Authentication cache failed: %r") % (errmsg)) pass try: user_filter = self.config_get_raw('user_filter') % ( {'base_dn': base_dn} ) except TypeError, errmsg: user_filter = self.config_get_raw('user_filter') _filter = '(&(|' auth_attrs = self.config_get_list('auth_attributes') for attr in auth_attrs: _filter += "(%s=%s)" % (attr, login[0]) _filter += "(%s=%s@%s)" % (attr, login[0], realm) _filter += ')%s)' % (user_filter) entry_dn = None # Attempt to obtain an entry_dn from cache. try: entry_dn = auth_cache.get_entry(_filter) except Exception, errmsg: log.error(_("Authentication cache failed: %r") % (errmsg)) pass retval = False if entry_dn is None: _search = self.ldap.search_ext( base_dn, ldap.SCOPE_SUBTREE, _filter, ['entrydn'] ) try: ( _result_type, _result_data, _result_msgid, _result_controls ) = self.ldap.result3(_search) except ldap.SERVER_DOWN, errmsg: log.error(_("LDAP server unavailable: %r") % (errmsg)) log.error(_("%s") % (traceback.format_exc())) self._disconnect() return False except ldap.NO_SUCH_OBJECT: log.error( _("Invalid DN, username and/or password for '%s'.") % ( bind_dn ) ) return False except ldap.INVALID_CREDENTIALS: log.error( _("Invalid DN, username and/or password for '%s'.") % ( bind_dn ) ) return False except Exception, errmsg: log.error(_("Exception occurred: %r") % (errmsg)) log.error(_("%s") % (traceback.format_exc())) self._disconnect() return False log.debug( _("Length of entries found: %r") % ( len(_result_data) ), level=8 ) # Remove referrals _result_data = [_e for _e in _result_data if _e[0] is not None] if len(_result_data) == 1: (entry_dn, entry_attrs) = _result_data[0] elif len(_result_data) > 1: try: log.info( _("Authentication for %r failed " + "(multiple entries)") % ( login[0] ) ) except: pass self._disconnect() return False else: try: log.info( _("Authentication for %r failed (no entry)") % ( login[0] ) ) except: pass self._disconnect() return False if entry_dn is None: try: log.info( _("Authentication for %r failed (LDAP error?)") % ( login[0] ) ) except: pass self._disconnect() return False try: # Needs to be synchronous or succeeds and continues setting # retval to True!! retval = self._bind(entry_dn, login[1]) if retval: try: log.info( _("Authentication for %r succeeded") % ( login[0] ) ) except: pass else: try: log.info( _("Authentication for %r failed (error)") % ( login[0] ) ) except: pass self._disconnect() return False try: auth_cache.set_entry(_filter, entry_dn) except Exception, errmsg: log.error(_("Authentication cache failed: %r") % (errmsg)) pass except ldap.SERVER_DOWN, errmsg: log.error(_("Authentication failed, LDAP server unavailable")) self._disconnect() return False except Exception, errmsg: try: log.debug( _("Failed to authenticate as user %r") % ( login[0] ), level=8 ) except: pass self._disconnect() return False else: try: # Needs to be synchronous or succeeds and continues setting # retval to True!! retval = self._bind(entry_dn, login[1]) if retval: log.info(_("Authentication for %r succeeded") % (login[0])) else: log.info( _("Authentication for %r failed (password)") % ( login[0] ) ) self._disconnect() return False auth_cache.set_entry(_filter, entry_dn) except ldap.NO_SUCH_OBJECT, errmsg: log.debug( _("Error occured, there is no such object: %r") % ( errmsg ), level=8 ) self.bind = None try: auth_cache.del_entry(_filter) except: log.error(_("Authentication cache failed to clear entry")) pass retval = self.authenticate(login, realm) except Exception, errmsg: log.debug(_("Exception occured: %r") %(errmsg)) try: log.debug( _("Failed to authenticate as user %r") % ( login[0] ), level=8 ) except: pass self._disconnect() return False self._disconnect() return retval def connect(self, priv=None, immediate=False): """ Connect to the LDAP server through the uri configured. """ # Already connected if priv is None and self.ldap is not None: return # Already connected if priv is not None and self.ldap_priv is not None: return log.debug(_("Connecting to LDAP..."), level=8) uri = self.config_get('ldap_uri') log.debug(_("Attempting to use LDAP URI %s") % (uri), level=8) trace_level = 0 if conf.debuglevel > 8: trace_level = 1 if immediate: retry_max = 1 retry_delay = 1.0 else: retry_max = 200 retry_delay = 3.0 conn = ldap.ldapobject.ReconnectLDAPObject( uri, trace_level=trace_level, + trace_file=pykolab.logger.StderrToLogger(log), retry_max=retry_max, retry_delay=retry_delay ) if immediate: conn.set_option(ldap.OPT_TIMEOUT, 10) conn.protocol_version = 3 conn.supported_controls = [] if priv is None: self.ldap = conn else: self.ldap_priv = conn def entry_dn(self, entry_id): """ Get a entry's distinguished name for an entry ID. The entry ID may be any of: - an entry's value for the configured unique_attribute, - a (syntactically valid) Distinguished Name, - a dictionary such as previously returned as (part of) the result of a search. """ entry_dn = None if self._entry_dn(entry_id): return entry_id if self._entry_dict(entry_id): return entry_id['dn'] unique_attribute = self.config_get('unique_attribute') config_base_dn = self.config_get('base_dn') ldap_base_dn = self._kolab_domain_root_dn(self.domain) if not ldap_base_dn == None and not ldap_base_dn == config_base_dn: base_dn = ldap_base_dn else: base_dn = config_base_dn _search = self.ldap.search_ext( base_dn, ldap.SCOPE_SUBTREE, '(%s=%s)' % (unique_attribute, entry_id), ['entrydn'] ) ( _result_type, _result_data, _result_msgid, _result_controls ) = self.ldap.result3(_search) if len(_result_data) >= 1: (entry_dn, entry_attrs) = _result_data[0] return entry_dn def get_entry_attribute(self, entry_id, attribute): """ Get an attribute for an entry. Return the attribute value if successful, or None if not. """ entry_attrs = self.get_entry_attributes(entry_id, [attribute]) if entry_attrs.has_key(attribute): return entry_attrs[attribute] elif entry_attrs.has_key(attribute.lower()): return entry_attrs[attribute.lower()] else: return None def get_entry_attributes(self, entry_id, attributes): """ Get multiple attributes for an entry. """ self._bind() - log.debug(_("Entry ID: %r") % (entry_id), level=9) + log.debug(_("Entry ID: %r") % (entry_id), level=8) entry_dn = self.entry_dn(entry_id) - log.debug(_("Entry DN: %r") % (entry_dn), level=9) + log.debug(_("Entry DN: %r") % (entry_dn), level=8) log.debug( _("ldap search: (%r, %r, filterstr='(objectclass=*)', attrlist=[ 'dn' ] + %r") % ( entry_dn, ldap.SCOPE_BASE, attributes ), - level=9 + level=8 ) _search = self.ldap.search_ext( entry_dn, ldap.SCOPE_BASE, filterstr='(objectclass=*)', attrlist=[ 'dn' ] + attributes ) ( _result_type, _result_data, _result_msgid, _result_controls ) = self.ldap.result3(_search) if len(_result_data) >= 1: (_entry_dn, _entry_attrs) = _result_data[0] else: return None return utils.normalize(_entry_attrs) def list_recipient_addresses(self, entry_id): """ Give a list of all valid recipient addresses for an LDAP entry identified by its ID. """ mail_attributes = conf.get_list('ldap', 'mail_attributes') entry = self.get_entry_attributes(entry_id, mail_attributes) return self.extract_recipient_addresses(entry) if entry is not None else [] def extract_recipient_addresses(self, entry): """ Extact a list of all valid recipient addresses for the given LDAP entry. This includes all attributes configured for ldap.mail_attributes """ recipient_addresses = [] mail_attributes = conf.get_list('ldap', 'mail_attributes') for attr in mail_attributes: if entry.has_key(attr): if isinstance(entry[attr], list): recipient_addresses.extend(entry[attr]) elif isinstance(entry[attr], basestring): recipient_addresses.append(entry[attr]) return recipient_addresses def list_delegators(self, entry_id): """ Get a list of user records the given user is set to be a delegatee """ delegators = [] mailbox_attribute = conf.get('cyrus-sasl', 'result_attribute') if mailbox_attribute == None: mailbox_attribute = 'mail' for __delegator in self.search_entry_by_attribute('kolabDelegate', entry_id): (_dn, _delegator) = __delegator _delegator['dn'] = _dn; _delegator['_mailbox_basename'] = _delegator[mailbox_attribute] if _delegator.has_key(mailbox_attribute) else None if isinstance(_delegator['_mailbox_basename'], list): _delegator['_mailbox_basename'] = _delegator['_mailbox_basename'][0] delegators.append(_delegator) return delegators def find_folder_resource(self, folder="*", exclude_entry_id=None): """ Given a shared folder name or list of folder names, find one or more valid resources. Specify an additional entry_id to exclude to exclude matches. """ self._bind() if not exclude_entry_id == None: __filter_prefix = "(&" __filter_suffix = "(!(%s=%s)))" % ( self.config_get('unique_attribute'), exclude_entry_id ) else: __filter_prefix = "" __filter_suffix = "" resource_filter = self.config_get('resource_filter') if not resource_filter == None: __filter_prefix = "(&%s" % resource_filter __filter_suffix = ")" recipient_address_attrs = self.config_get_list("mail_attributes") result_attributes = recipient_address_attrs result_attributes.append(self.config_get('unique_attribute')) result_attributes.append('kolabTargetFolder') _filter = "(|" if isinstance(folder, basestring): _filter += "(kolabTargetFolder=%s)" % (folder) else: for _folder in folder: _filter += "(kolabTargetFolder=%s)" % (_folder) _filter += ")" _filter = "%s%s%s" % (__filter_prefix,_filter,__filter_suffix) log.debug(_("Finding resource with filter %r") % (_filter), level=8) if len(_filter) <= 6: return None config_base_dn = self.config_get('resource_base_dn') ldap_base_dn = self._kolab_domain_root_dn(self.domain) if not ldap_base_dn == None and not ldap_base_dn == config_base_dn: resource_base_dn = ldap_base_dn else: resource_base_dn = config_base_dn _results = self.ldap.search_s( resource_base_dn, scope=ldap.SCOPE_SUBTREE, filterstr=_filter, attrlist=result_attributes, attrsonly=True ) _entry_dns = [] for _result in _results: (_entry_id, _entry_attrs) = _result _entry_dns.append(_entry_id) return _entry_dns def find_recipient(self, address="*", exclude_entry_id=None, search_attrs=None): """ Given an address string or list of addresses, find one or more valid recipients. Use this function only to detect whether an address is already in use by any entry in the tree. Specify an additional entry_id to exclude to exclude matches against the current entry. In search_attrs you can specify list of search attributes. By default mail_attributes are used. """ self._bind() if not exclude_entry_id == None: __filter_prefix = "(&" __filter_suffix = "(!(%s=%s)))" % ( self.config_get('unique_attribute'), exclude_entry_id ) else: __filter_prefix = "" __filter_suffix = "" kolab_filter = self._kolab_filter() if search_attrs is not None: recipient_address_attrs = search_attrs else: recipient_address_attrs = self.config_get_list("mail_attributes") result_attributes = recipient_address_attrs result_attributes.append(self.config_get('unique_attribute')) _filter = "(|" for recipient_address_attr in recipient_address_attrs: if isinstance(address, basestring): _filter += "(%s=%s)" % (recipient_address_attr, address) else: for _address in address: _filter += "(%s=%s)" % (recipient_address_attr, _address) _filter += ")" _filter = "%s%s%s" % (__filter_prefix,_filter,__filter_suffix) log.debug(_("Finding recipient with filter %r") % (_filter), level=8) if len(_filter) <= 6: return None config_base_dn = self.config_get('base_dn') ldap_base_dn = self._kolab_domain_root_dn(self.domain) if not ldap_base_dn == None and not ldap_base_dn == config_base_dn: base_dn = ldap_base_dn else: base_dn = config_base_dn _results = self.ldap.search_s( base_dn, scope=ldap.SCOPE_SUBTREE, filterstr=_filter, attrlist=result_attributes, attrsonly=True ) _entry_dns = [] for _result in _results: (_entry_id, _entry_attrs) = _result # Prevent Active Directory referrals if not _entry_id == None: _entry_dns.append(_entry_id) return _entry_dns def find_resource(self, address="*", exclude_entry_id=None): """ Given an address string or list of addresses, find one or more valid resources. Specify an additional entry_id to exclude to exclude matches. """ self._bind() if not exclude_entry_id == None: __filter_prefix = "(&" __filter_suffix = "(!(%s=%s)))" % ( self.config_get('unique_attribute'), exclude_entry_id ) else: __filter_prefix = "" __filter_suffix = "" resource_filter = self.config_get('resource_filter') if not resource_filter == None: __filter_prefix = "(&%s" % resource_filter __filter_suffix = ")" recipient_address_attrs = self.config_get_list("mail_attributes") result_attributes = recipient_address_attrs result_attributes.append(self.config_get('unique_attribute')) _filter = "(|" for recipient_address_attr in recipient_address_attrs: if isinstance(address, basestring): _filter += "(%s=%s)" % (recipient_address_attr, address) else: for _address in address: _filter += "(%s=%s)" % (recipient_address_attr, _address) _filter += ")" _filter = "%s%s%s" % (__filter_prefix,_filter,__filter_suffix) log.debug(_("Finding resource with filter %r") % (_filter), level=8) if len(_filter) <= 6: return None config_base_dn = self.config_get('resource_base_dn') ldap_base_dn = self._kolab_domain_root_dn(self.domain) if not ldap_base_dn == None and not ldap_base_dn == config_base_dn: resource_base_dn = ldap_base_dn else: resource_base_dn = config_base_dn _results = self.ldap.search_s( resource_base_dn, scope=ldap.SCOPE_SUBTREE, filterstr=_filter, attrlist=result_attributes, attrsonly=True ) _entry_dns = [] for _result in _results: (_entry_id, _entry_attrs) = _result _entry_dns.append(_entry_id) return _entry_dns def get_latest_sync_timestamp(self): timestamp = cache.last_modify_timestamp(self.domain) - log.debug(_("Using timestamp %r") % (timestamp), level=9) + log.debug(_("Using timestamp %r") % (timestamp), level=8) return timestamp def list_secondary_domains(self): """ List alias domain name spaces for the current domain name space. """ if not self.domains == None: return [s for s in self.domains.keys() if not s in self.domains.values()] else: return [] def recipient_policy(self, entry): """ Apply a recipient policy, if configured. Given an entry, returns the entry's attribute values to be set. """ entry_dn = self.entry_dn(entry) entry_modifications = {} entry_type = self._entry_type(entry) mail_attributes = self.config_get_list('mail_attributes') primary_mail = None primary_mail_attribute = None secondary_mail = None secondary_mail_attribute = None if len(mail_attributes) >= 1: primary_mail_attribute = mail_attributes[0] if len(mail_attributes) >= 2: secondary_mail_attribute = mail_attributes[1] daemon_rcpt_policy = self.config_get('daemon_rcpt_policy') if not utils.true_or_false(daemon_rcpt_policy) and not daemon_rcpt_policy == None: log.debug( _( "Not applying recipient policy for %s " + \ "(disabled through configuration)" ) % (entry_dn), level=1 ) return entry_modifications want_attrs = [] log.debug(_("Applying recipient policy to %r") % (entry_dn), level=8) # See which mail attributes we would want to control. # # 'mail' is considered for primary_mail, # 'alias' and 'mailalternateaddress' are considered for secondary mail. # primary_mail = self.config_get_raw('%s_primary_mail' % (entry_type)) if primary_mail == None and entry_type == 'user': primary_mail = self.config_get_raw('primary_mail') if not secondary_mail_attribute == None: secondary_mail = self.config_get_raw('%s_secondary_mail' % (entry_type)) if secondary_mail == None and entry_type == 'user': secondary_mail = self.config_get_raw('secondary_mail') log.debug( _("Using mail attributes: %r, with primary %r and " + \ "secondary %r") % ( mail_attributes, primary_mail_attribute, secondary_mail_attribute ), level=8 ) for _mail_attr in mail_attributes: if not entry.has_key(_mail_attr): log.debug(_("key %r not in entry") % (_mail_attr), level=8) if _mail_attr == primary_mail_attribute: log.debug(_("key %r is the prim. mail attr.") % (_mail_attr), level=8) if not primary_mail == None: log.debug(_("prim. mail pol. is not empty"), level=8) want_attrs.append(_mail_attr) elif _mail_attr == secondary_mail_attribute: log.debug(_("key %r is the sec. mail attr.") % (_mail_attr), level=8) if not secondary_mail == None: log.debug(_("sec. mail pol. is not empty"), level=8) want_attrs.append(_mail_attr) if len(want_attrs) > 0: log.debug(_("Attributes %r are not yet available for entry %r") % ( want_attrs, entry_dn ), level=8 ) # Also append the preferredlanguage or 'native tongue' configured # for the entry. if not entry.has_key('preferredlanguage'): want_attrs.append('preferredlanguage') # If we wanted anything, now is the time to get it. if len(want_attrs) > 0: log.debug(_("Attributes %r are not yet available for entry %r") % ( want_attrs, entry_dn ), level=8 ) attributes = self.get_entry_attributes(entry_dn, want_attrs) for attribute in attributes.keys(): entry[attribute] = attributes[attribute] if not entry.has_key('preferredlanguage'): entry['preferredlanguage'] = conf.get('kolab', 'default_locale') # Primary mail address if not primary_mail == None: primary_mail_address = conf.plugins.exec_hook( "set_primary_mail", kw={ 'primary_mail': primary_mail, 'entry': entry, 'primary_domain': self.domain } ) if primary_mail_address == None: return entry_modifications i = 1 _primary_mail = primary_mail_address done = False while not done: results = self.find_recipient(_primary_mail, entry['id']) # Length of results should be 0 (no entry found) # or 1 (which should be the entry we're looking at here) if len(results) == 0: log.debug( _("No results for mail address %s found") % ( _primary_mail ), level=8 ) done = True continue if len(results) == 1: log.debug( _("1 result for address %s found, verifying") % ( _primary_mail ), level=8 ) almost_done = True for result in results: if not result == entry_dn: log.debug( _("Too bad, primary email address %s " + \ "already in use for %s (we are %s)") % ( _primary_mail, result, entry_dn ), level=8 ) almost_done = False else: log.debug(_("Address assigned to us"), level=8) if almost_done: done = True continue i += 1 _primary_mail = "%s%d@%s" % ( primary_mail_address.split('@')[0], i, primary_mail_address.split('@')[1] ) primary_mail_address = _primary_mail ### ### FIXME ### if not primary_mail_address == None: if not entry.has_key(primary_mail_attribute): self.set_entry_attribute(entry, primary_mail_attribute, primary_mail_address) entry_modifications[primary_mail_attribute] = primary_mail_address else: if not primary_mail_address == entry[primary_mail_attribute]: self.set_entry_attribute(entry, primary_mail_attribute, primary_mail_address) entry_modifications[primary_mail_attribute] = primary_mail_address if not secondary_mail == None: # Execute the plugin hook suggested_secondary_mail = conf.plugins.exec_hook( "set_secondary_mail", kw={ 'secondary_mail': secondary_mail, 'entry': entry, 'domain': self.domain, 'primary_domain': self.domain, 'secondary_domains': self.list_secondary_domains() } ) # end of conf.plugins.exec_hook() call secondary_mail_addresses = [] for _secondary_mail in suggested_secondary_mail: i = 1 __secondary_mail = _secondary_mail done = False while not done: results = self.find_recipient(__secondary_mail, entry['id']) # Length of results should be 0 (no entry found) # or 1 (which should be the entry we're looking at here) if len(results) == 0: log.debug( _("No results for address %s found") % ( __secondary_mail ), level=8 ) done = True continue if len(results) == 1: log.debug( _("1 result for address %s found, " + \ "verifying...") % ( __secondary_mail ), level=8 ) almost_done = True for result in results: if not result == entry_dn: log.debug( _("Too bad, secondary email " + \ "address %s already in use for " + \ "%s (we are %s)") % ( __secondary_mail, result, entry_dn ), level=8 ) almost_done = False else: log.debug(_("Address assigned to us"), level=8) if almost_done: done = True continue i += 1 __secondary_mail = "%s%d@%s" % ( _secondary_mail.split('@')[0], i, _secondary_mail.split('@')[1] ) secondary_mail_addresses.append(__secondary_mail) log.debug(_("Recipient policy composed the following set of secondary " + \ "email addresses: %r") % (secondary_mail_addresses), level=8) if entry.has_key(secondary_mail_attribute): if isinstance(entry[secondary_mail_attribute], list): secondary_mail_addresses.extend(entry[secondary_mail_attribute]) else: secondary_mail_addresses.append(entry[secondary_mail_attribute]) if not secondary_mail_addresses == None: log.debug( _("Secondary mail addresses that we want is not None: %r") % ( secondary_mail_addresses ), - level=9 + level=8 ) secondary_mail_addresses = list(set(secondary_mail_addresses)) # Avoid duplicates while primary_mail_address in secondary_mail_addresses: log.debug( _("Avoiding the duplication of the primary mail " + \ "address %r in the list of secondary mail " + \ "addresses") % (primary_mail_address), - level=9 + level=8 ) secondary_mail_addresses.pop( secondary_mail_addresses.index(primary_mail_address) ) log.debug( _("Entry is getting secondary mail addresses: %r") % ( secondary_mail_addresses ), - level=9 + level=8 ) if not entry.has_key(secondary_mail_attribute): log.debug( _("Entry did not have any secondary mail " + \ "addresses in %r") % (secondary_mail_attribute), - level=9 + level=8 ) if not len(secondary_mail_addresses) == 0: self.set_entry_attribute( entry, secondary_mail_attribute, secondary_mail_addresses ) entry_modifications[secondary_mail_attribute] = secondary_mail_addresses else: if isinstance(entry[secondary_mail_attribute], basestring): entry[secondary_mail_attribute] = [entry[secondary_mail_attribute]] log.debug(_("secondary_mail_addresses: %r") % (secondary_mail_addresses), level=8) log.debug(_("entry[%s]: %r") % (secondary_mail_attribute,entry[secondary_mail_attribute]), level=8) secondary_mail_addresses.sort() entry[secondary_mail_attribute].sort() log.debug(_("secondary_mail_addresses: %r") % (secondary_mail_addresses), level=8) log.debug(_("entry[%s]: %r") % (secondary_mail_attribute,entry[secondary_mail_attribute]), level=8) if not list(set(secondary_mail_addresses)) == list(set(entry[secondary_mail_attribute])): self.set_entry_attribute( entry, secondary_mail_attribute, list(set(secondary_mail_addresses)) ) entry_modifications[secondary_mail_attribute] = list(set(secondary_mail_addresses)) log.debug(_("Entry modifications list: %r") % (entry_modifications), level=8) return entry_modifications def reconnect(self): bind = self.bind self._disconnect() self.connect() if bind is not None: self._bind(bind['dn'], bind['pw']) def search_entry_by_attribute(self, attr, value, **kw): self._bind() _filter = "(%s=%s)" % (attr, ldap.filter.escape_filter_chars(value)) config_base_dn = self.config_get('base_dn') ldap_base_dn = self._kolab_domain_root_dn(self.domain) if not ldap_base_dn == None and not ldap_base_dn == config_base_dn: base_dn = ldap_base_dn else: base_dn = config_base_dn return self._search( base_dn, filterstr=_filter, attrlist=[ '*', ], override_search='_regular_search' ) def set_entry_attribute(self, entry_id, attribute, value): - log.debug(_("Setting entry attribute %r to %r for %r") % (attribute, value, entry_id), level=9) + log.debug(_("Setting entry attribute %r to %r for %r") % (attribute, value, entry_id), level=8) self.set_entry_attributes(entry_id, { attribute: value }) def set_entry_attributes(self, entry_id, attributes): self._bind() entry_dn = self.entry_dn(entry_id) entry = self.get_entry_attributes(entry_dn, ['*']) attrs = {} for attribute in attributes.keys(): attrs[attribute.lower()] = attributes[attribute] modlist = [] for attribute in attrs.keys(): if not entry.has_key(attribute): entry[attribute] = self.get_entry_attribute(entry_id, attribute) for attribute in attrs.keys(): if entry.has_key(attribute) and entry[attribute] == None: modlist.append((ldap.MOD_ADD, attribute, attrs[attribute])) elif entry.has_key(attribute) and not entry[attribute] == None: if attrs[attribute] == None: modlist.append((ldap.MOD_DELETE, attribute, entry[attribute])) else: modlist.append((ldap.MOD_REPLACE, attribute, attrs[attribute])) dn = entry_dn if len(modlist) > 0 and self._bind_priv() is True: try: self.ldap_priv.modify_s(dn, modlist) except Exception, errmsg: log.error(_("Could not update dn:\nDN: %r\nModlist: %r\nError Message: %r") % (dn, modlist, errmsg)) import traceback log.error("%s" % (traceback.format_exc())) def synchronize(self, mode=0, callback=None): """ Synchronize with LDAP """ self._bind() _filter = self._kolab_filter() modified_after = None if hasattr(conf, 'resync'): if not conf.resync: modified_after = self.get_latest_sync_timestamp() else: modifytimestamp_format = conf.get_raw('ldap', 'modifytimestamp_format') if modifytimestamp_format == None: modifytimestamp_format = "%Y%m%d%H%M%SZ" modified_after = datetime.datetime(1900, 01, 01, 00, 00, 00).strftime(modifytimestamp_format) else: modified_after = self.get_latest_sync_timestamp() _filter = "(&%s(modifytimestamp>=%s))" % (_filter,modified_after) log.debug(_("Using filter %r") % (_filter), level=8) if not mode == 0: override_search = mode else: override_search = False config_base_dn = self.config_get('base_dn') ldap_base_dn = self._kolab_domain_root_dn(self.domain) if not ldap_base_dn == None and not ldap_base_dn == config_base_dn: base_dn = ldap_base_dn else: base_dn = config_base_dn log.debug(_("Synchronization is searching against base DN: %s") % (base_dn), level=8) if callback == None: callback = self._synchronize_callback try: self._search( base_dn, filterstr=_filter, attrlist=[ '*', self.config_get('unique_attribute'), conf.get('cyrus-sasl', 'result_attribute'), 'modifytimestamp' ], override_search=override_search, callback=callback, ) except Exception, errmsg: log.error("An error occurred: %r" % (errmsg)) log.error(_("%s") % (traceback.format_exc())) def user_quota(self, entry_id, folder): default_quota = self.config_get('default_quota') quota_attribute = self.config_get('quota_attribute') if quota_attribute == None: return # The default quota may be None, but LDAP quota could still be set if default_quota == None: default_quota = 0 self._bind() entry_dn = self.entry_dn(entry_id) current_ldap_quota = self.get_entry_attribute(entry_dn, quota_attribute) _imap_quota = self.imap.get_quota(folder) if _imap_quota == None: used = None current_imap_quota = None else: (used, current_imap_quota) = _imap_quota log.debug( _("About to consider the user quota for %r (used: %r, " + \ "imap: %r, ldap: %r, default: %r") % ( entry_dn, used, current_imap_quota, current_ldap_quota, default_quota ), - level=9 + level=8 ) new_quota = conf.plugins.exec_hook("set_user_folder_quota", kw={ 'used': used, 'imap_quota': current_imap_quota, 'ldap_quota': current_ldap_quota, 'default_quota': default_quota } ) try: current_ldap_quota = (int)(current_ldap_quota) except: current_ldap_quota = None # If the new quota is zero, get out if new_quota == 0: return if not current_ldap_quota == None: if not new_quota == (int)(current_ldap_quota): self.set_entry_attribute( entry_dn, quota_attribute, "%s" % (new_quota) ) else: if not new_quota == None: self.set_entry_attribute( entry_dn, quota_attribute, "%s" % (new_quota) ) if not current_imap_quota == None: if not new_quota == current_imap_quota: self.imap.set_quota(folder, new_quota) else: if not new_quota == None: self.imap.set_quota(folder, new_quota) ### ### API depth level increasing! ### def _bind(self, bind_dn=None, bind_pw=None): # If we have no LDAP, we have no previous state. if self.ldap is None: self.bind = None self.connect() # If the bind_dn is None and the bind_pw is not... fail if bind_dn is None and bind_pw is not None: log.error(_("Attempting to bind without a DN but with a password")) return False # and the same vice-versa if bind_dn is not None and bind_pw is None: log.error(_("Attempting to bind with a DN but without a password")) return False # If we are to bind as foo, we have no state. if bind_dn is not None: self.bind = None # Only if we have no state and no bind credentials specified in the # function call. if self.bind is None: if bind_dn is None: bind_dn = self.config_get('service_bind_dn') if bind_pw is None: bind_pw = self.config_get('service_bind_pw') if bind_dn is not None: log.debug( _("Binding with bind_dn: %s and password: %s") % ( bind_dn, '*' * len(bind_pw) ), level=8 ) # TODO: Binding errors control try: # Must be synchronous self.ldap.simple_bind_s(bind_dn, bind_pw) self.bind = {'dn': bind_dn, 'pw': bind_pw} return True except ldap.SERVER_DOWN, errmsg: log.error(_("LDAP server unavailable: %r") % (errmsg)) log.error(_("%s") % (traceback.format_exc())) return False except ldap.NO_SUCH_OBJECT: log.error( _("Invalid DN, username and/or password for '%s'.") % ( bind_dn ) ) return False except ldap.INVALID_CREDENTIALS: log.error( _("Invalid DN, username and/or password for '%s'.") % ( bind_dn ) ) return False else: log.debug(_("bind() called but already bound"), level=8) return True def _bind_priv(self): if self.ldap_priv is None: self.connect(True) bind_dn = self.config_get('bind_dn') bind_pw = self.config_get('bind_pw') try: self.ldap_priv.simple_bind_s(bind_dn, bind_pw) return True except ldap.SERVER_DOWN, errmsg: log.error(_("LDAP server unavailable: %r") % (errmsg)) log.error(_("%s") % (traceback.format_exc())) return False except ldap.INVALID_CREDENTIALS: log.error( _("Invalid DN, username and/or password for '%s'.") % ( bind_dn ) ) return False else: log.debug(_("bind_priv() called but already bound"), level=8) return True def _change_add_group(self, entry, change): """ An entry of type group was added. The Kolab daemon has little to do for this type of action on this type of entry. """ pass def _change_add_None(self, entry, change): """ Redirect to _change_add_unknown """ self._change_add_unknown(entry, change) def _change_add_resource(self, entry, change): """ An entry of type resource was added. The Kolab daemon has little to do for this type of action on this type of entry. """ pass def _change_add_role(self, entry, change): """ An entry of type role was added. The Kolab daemon has little to do for this type of action on this type of entry. """ pass def _change_add_sharedfolder(self, entry, change): """ An entry of type sharedfolder was added. """ self.imap.connect(domain=self.domain) server = None # Get some configuration values mailserver_attribute = self.config_get('mailserver_attribute') if entry.has_key(mailserver_attribute): server = entry[mailserver_attribute] foldertype_attribute = self.config_get('sharedfolder_type_attribute') if not foldertype_attribute == None: if not entry.has_key(foldertype_attribute): entry[foldertype_attribute] = self.get_entry_attribute( entry['id'], foldertype_attribute ) if not entry[foldertype_attribute] == None: entry['kolabfoldertype'] = entry[foldertype_attribute] if not entry.has_key('kolabfoldertype'): entry['kolabfoldertype'] = self.get_entry_attribute( entry['id'], 'kolabfoldertype' ) # A delivery address is postuser+targetfolder delivery_address_attribute = self.config_get('sharedfolder_delivery_address_attribute') if delivery_address_attribute == None: delivery_address_attribute = 'mail' if not entry.has_key(delivery_address_attribute): entry[delivery_address_attribute] = self.get_entry_attribute( entry['id'], delivery_address_attribute ) if not entry[delivery_address_attribute] == None: if len(entry[delivery_address_attribute].split('+')) > 1: entry['kolabtargetfolder'] = entry[delivery_address_attribute].split('+')[1] if not entry.has_key('kolabtargetfolder'): entry['kolabtargetfolder'] = self.get_entry_attribute( entry['id'], 'kolabtargetfolder' ) if entry.has_key('kolabtargetfolder') and \ not entry['kolabtargetfolder'] == None: folder_path = entry['kolabtargetfolder'] else: # TODO: What is *the* way to see if we need to create an @domain # shared mailbox? # TODO^2: self.domain, really? Presumes any mail attribute is # set to the primary domain name space... # TODO^3: Test if the cn is already something@domain result_attribute = conf.get('cyrus-sasl', 'result_attribute') if result_attribute in ['mail']: folder_path = "%s@%s" % (entry['cn'], self.domain) else: folder_path = entry['cn'] if not folder_path.startswith('shared/'): folder_path = "shared/%s" % folder_path folderacl_entry_attribute = self.config_get('sharedfolder_acl_entry_attribute') if folderacl_entry_attribute == None: folderacl_entry_attribute = 'acl' if not entry.has_key(folderacl_entry_attribute): entry[folderacl_entry_attribute] = self.get_entry_attribute( entry['id'], folderacl_entry_attribute ) if not self.imap.shared_folder_exists(folder_path): self.imap.shared_folder_create(folder_path, server) if entry.has_key('kolabfoldertype') and \ not entry['kolabfoldertype'] == None: self.imap.shared_folder_set_type( folder_path, entry['kolabfoldertype'] ) entry['kolabfolderaclentry'] = self._parse_acl(entry[folderacl_entry_attribute]) self.imap._set_kolab_mailfolder_acls( entry['kolabfolderaclentry'], folder_path ) if entry.has_key(delivery_address_attribute) and \ not entry[delivery_address_attribute] == None: self.imap.set_acl(folder_path, 'anyone', '+p') #if server == None: #self.entry_set_attribute(mailserver_attribute, server) def _change_add_unknown(self, entry, change): """ An entry has been add, and we do not know of what object type the entry was - user, group, role or sharedfolder. """ result_attribute = conf.get('cyrus-sasl', 'result_attribute') if not entry.has_key(result_attribute): return None if entry[result_attribute] == None: return None for _type in ['user','group','role','sharedfolder']: try: eval("self._change_add_%s(entry, change)" % (_type)) success = True except: success = False if success: break def _change_add_user(self, entry, change): """ An entry of type user was added. """ mailserver_attribute = self.config_get('mailserver_attribute') if mailserver_attribute == None: mailserver_attribute = 'mailhost' mailserver_attribute = mailserver_attribute.lower() result_attribute = conf.get('cyrus-sasl', 'result_attribute') if result_attribute == None: result_attribute = 'mail' result_attribute = result_attribute.lower() if not entry.has_key(mailserver_attribute): entry[mailserver_attribute] = \ self.get_entry_attribute(entry, mailserver_attribute) rcpt_addrs = self.recipient_policy(entry) for key in rcpt_addrs: entry[key] = rcpt_addrs[key] if not entry.has_key(result_attribute): return if entry[result_attribute] == None: return if entry[result_attribute] == '': return cache.get_entry(self.domain, entry) self.imap.connect(domain=self.domain) if not self.imap.user_mailbox_exists(entry[result_attribute].lower()): folder = self.imap.user_mailbox_create( entry[result_attribute], entry[mailserver_attribute] ) else: folder = "user%s%s" % (self.imap.get_separator(),entry[result_attribute].lower()) server = self.imap.user_mailbox_server(folder) log.debug( _("Entry %s attribute value: %r") % ( mailserver_attribute, entry[mailserver_attribute] ), level=8 ) log.debug( _("imap.user_mailbox_server(%r) result: %r") % ( folder, server ), level=8 ) if not entry[mailserver_attribute] == server: self.set_entry_attribute(entry, mailserver_attribute, server) self.user_quota(entry, folder) def _change_delete_group(self, entry, change): """ An entry of type group was deleted. """ result_attribute = conf.get('cyrus-sasl', 'result_attribute') if not entry.has_key(result_attribute): return None if entry[result_attribute] == None: return None self.imap.cleanup_acls(entry[result_attribute]) def _change_delete_None(self, entry, change): """ Redirect to _change_delete_unknown """ self._change_delete_unknown(entry, change) def _change_delete_resource(self, entry, change): pass def _change_delete_role(self, entry, change): pass def _change_delete_sharedfolder(self, entry, change): pass def _change_delete_unknown(self, entry, change): """ An entry has been deleted, and we do not know of what object type the entry was - user, group, resource, role or sharedfolder. """ result_attribute = conf.get('cyrus-sasl', 'result_attribute') if not entry.has_key(result_attribute): return None if entry[result_attribute] == None: return None success = True for _type in ['user','group','resource','role','sharedfolder']: try: success = eval("self._change_delete_%s(entry, change)" % (_type)) except Exception, errmsg: log.error(_("An error occured: %r") % (errmsg)) log.error(_("%s") % (traceback.format_exc())) success = False if success: break def _change_delete_user(self, entry, change): """ An entry of type user was deleted. """ result_attribute = conf.get('cyrus-sasl', 'result_attribute') if not entry.has_key(result_attribute): return None if entry[result_attribute] == None: return None cache.delete_entry(self.domain, entry) self.imap.user_mailbox_delete(entry[result_attribute]) self.imap.cleanup_acls(entry[result_attribute]) # let plugins act upon this deletion conf.plugins.exec_hook( 'user_delete', kw = { 'user': entry, 'domain': self.domain } ) def _change_moddn_group(self, entry, change): # TODO: If the rdn attribute is the same as the result attribute... pass def _change_moddn_role(self, entry, change): pass def _change_moddn_user(self, entry, change): old_dn = change['previous_dn'] new_dn = change['dn'] import ldap.dn old_rdn = ldap.dn.explode_dn(old_dn)[0].split('=')[0] new_rdn = ldap.dn.explode_dn(new_dn)[0].split('=')[0] result_attribute = conf.get('cyrus-sasl', 'result_attribute') old_canon_attr = None cache_entry = cache.get_entry(self.domain, entry) if not cache_entry == None: old_canon_attr = cache_entry.result_attribute # See if we have to trigger the recipient policy. Only really applies to # situations in which the result_attribute is used in the old or in the # new DN. trigger_recipient_policy = False if old_rdn == result_attribute: if new_rdn == result_attribute: if new_rdn == old_rdn: trigger_recipient_policy = True else: if not new_rdn == old_rdn: trigger_recipient_policy = True else: if new_rdn == result_attribute: if not new_rdn == old_rdn: trigger_recipient_policy = True if trigger_recipient_policy: entry_changes = self.recipient_policy(entry) for key in entry_changes.keys(): entry[key] = entry_changes[key] if not entry.has_key(result_attribute): return if entry[result_attribute] == None: return if entry[result_attribute] == '': return # Now look at entry_changes and old_canon_attr, and see if they're # the same value. if entry_changes.has_key(result_attribute): if not old_canon_attr == None: self.imap.user_mailbox_create( entry_changes[result_attribute] ) elif not entry_changes[result_attribute] == old_canon_attr: self.imap.user_mailbox_rename( old_canon_attr, entry_changes[result_attribute] ) cache.get_entry(self.domain, entry) def _change_moddn_sharedfolder(self, entry, change): pass def _change_modify_None(self, entry, change): pass def _change_modify_group(self, entry, change): pass def _change_modify_role(self, entry, change): pass def _change_modify_sharedfolder(self, entry, change): """ A shared folder was modified. """ self.imap.connect(domain=self.domain) server = None # Get some configuration values mailserver_attribute = self.config_get('mailserver_attribute') if entry.has_key(mailserver_attribute): server = entry[mailserver_attribute] foldertype_attribute = self.config_get('sharedfolder_type_attribute') if not foldertype_attribute == None: if not entry.has_key(foldertype_attribute): entry[foldertype_attribute] = self.get_entry_attribute( entry['id'], foldertype_attribute ) if not entry[foldertype_attribute] == None: entry['kolabfoldertype'] = entry[foldertype_attribute] if not entry.has_key('kolabfoldertype'): entry['kolabfoldertype'] = self.get_entry_attribute( entry['id'], 'kolabfoldertype' ) # A delivery address is postuser+targetfolder delivery_address_attribute = self.config_get('sharedfolder_delivery_address_attribute') if not delivery_address_attribute == None: if not entry.has_key(delivery_address_attribute): entry[delivery_address_attribute] = self.get_entry_attribute( entry['id'], delivery_address_attribute ) if not entry[delivery_address_attribute] == None: if len(entry[delivery_address_attribute].split('+')) > 1: entry['kolabtargetfolder'] = entry[delivery_address_attribute].split('+')[1] if not entry.has_key('kolabtargetfolder'): entry['kolabtargetfolder'] = self.get_entry_attribute( entry['id'], 'kolabtargetfolder' ) if entry.has_key('kolabtargetfolder') and \ not entry['kolabtargetfolder'] == None: folder_path = entry['kolabtargetfolder'] else: # TODO: What is *the* way to see if we need to create an @domain # shared mailbox? # TODO^2: self.domain, really? Presumes any mail attribute is # set to the primary domain name space... # TODO^3: Test if the cn is already something@domain result_attribute = conf.get('cyrus-sasl', 'result_attribute') if result_attribute in ['mail']: folder_path = "%s@%s" % (entry['cn'], self.domain) else: folder_path = entry['cn'] if not folder_path.startswith('shared/'): folder_path = "shared/%s" % folder_path folderacl_entry_attribute = self.config_get('sharedfolder_acl_entry_attribute') if folderacl_entry_attribute == None: folderacl_entry_attribute = 'acl' if not entry.has_key(folderacl_entry_attribute): entry[folderacl_entry_attribute] = self.get_entry_attribute( entry['id'], folderacl_entry_attribute ) if not self.imap.shared_folder_exists(folder_path): self.imap.shared_folder_create(folder_path, server) if entry.has_key('kolabfoldertype') and \ not entry['kolabfoldertype'] == None: self.imap.shared_folder_set_type( folder_path, entry['kolabfoldertype'] ) entry['kolabfolderaclentry'] = self._parse_acl(entry[folderacl_entry_attribute]) self.imap._set_kolab_mailfolder_acls( entry['kolabfolderaclentry'], folder_path, True ) if entry.has_key(delivery_address_attribute) and \ not entry[delivery_address_attribute] == None: self.imap.set_acl(folder_path, 'anyone', '+p') def _change_modify_user(self, entry, change): """ Handle the changes for an object of type user. Expects the new entry. """ # Initialize old_canon_attr (#1701) old_canon_attr = None result_attribute = conf.get('cyrus-sasl','result_attribute') _entry = cache.get_entry(self.domain, entry, update=False) # We do not necessarily have a synchronisation cache entry (#1701) if not _entry == None: if _entry.__dict__.has_key('result_attribute') and not _entry.result_attribute == '': old_canon_attr = _entry.result_attribute entry_changes = self.recipient_policy(entry) log.debug( _("Result from recipient policy: %r") % (entry_changes), level=8 ) if entry_changes.has_key(result_attribute): if not entry_changes[result_attribute] == old_canon_attr: if old_canon_attr == None: self.imap.user_mailbox_create( entry_changes[result_attribute] ) else: self.imap.user_mailbox_rename( old_canon_attr, entry_changes[result_attribute] ) entry[result_attribute] = entry_changes[result_attribute] cache.get_entry(self.domain, entry) elif entry.has_key(result_attribute): if not entry[result_attribute] == old_canon_attr: if old_canon_attr == None: self.imap.user_mailbox_create( entry[result_attribute] ) else: self.imap.user_mailbox_rename( old_canon_attr, entry[result_attribute] ) cache.get_entry(self.domain, entry) else: if not self.imap.user_mailbox_exists(entry[result_attribute]): self.imap.user_mailbox_create( entry[result_attribute] ) self.user_quota( entry, "user%s%s" % ( self.imap.get_separator(), entry[result_attribute] ) ) if conf.has_option(self.domain, 'sieve_mgmt'): sieve_mgmt_enabled = conf.get(self.domain, 'sieve_mgmt') if utils.true_or_false(sieve_mgmt_enabled): conf.plugins.exec_hook( 'sieve_mgmt_refresh', kw={ 'user': entry[result_attribute] } ) def _change_none_group(self, entry, change): """ A group entry as part of the initial search result set. The Kolab daemon has little to do for this type of action on this type of entry. """ pass def _change_none_None(self, entry, change): pass def _change_none_role(self, entry, change): """ A role entry as part of the initial search result set. The Kolab daemon has little to do for this type of action on this type of entry. """ pass def _change_none_sharedfolder(self, entry, change): """ A sharedfolder entry as part of the initial search result set. """ self.imap.connect(domain=self.domain) server = None mailserver_attribute = self.config_get('mailserver_attribute') if entry.has_key(mailserver_attribute): server = entry[mailserver_attribute] if not entry.has_key('kolabtargetfolder'): entry['kolabtargetfolder'] = self.get_entry_attribute( entry['id'], 'kolabtargetfolder' ) if not entry.has_key('kolabfoldertype'): entry['kolabfoldertype'] = self.get_entry_attribute( entry['id'], 'kolabfoldertype' ) folderacl_entry_attribute = conf.get('ldap', 'sharedfolder_acl_entry_attribute') if folderacl_entry_attribute == None: folderacl_entry_attribute = 'acl' if not entry.has_key(folderacl_entry_attribute): entry['kolabfolderaclentry'] = self.get_entry_attribute( entry['id'], folderacl_entry_attribute ) else: entry['kolabfolderaclentry'] = entry[folderacl_entry_attribute] del entry[folderacl_entry_attribute] if entry.has_key('kolabtargetfolder') and \ not entry['kolabtargetfolder'] == None: folder_path = entry['kolabtargetfolder'] else: # TODO: What is *the* way to see if we need to create an @domain # shared mailbox? # TODO^2: self.domain, really? Presumes any mail attribute is # set to the primary domain name space... # TODO^3: Test if the cn is already something@domain result_attribute = conf.get('cyrus-sasl', 'result_attribute') if result_attribute in ['mail']: folder_path = "%s@%s" % (entry['cn'], self.domain) else: folder_path = entry['cn'] if not folder_path.startswith('shared/'): folder_path = "shared/%s" % folder_path if not self.imap.shared_folder_exists(folder_path): self.imap.shared_folder_create(folder_path, server) if entry.has_key('kolabfoldertype') and \ not entry['kolabfoldertype'] == None: self.imap.shared_folder_set_type( folder_path, entry['kolabfoldertype'] ) entry['kolabfolderaclentry'] = self._parse_acl(entry['kolabfolderaclentry']) self.imap._set_kolab_mailfolder_acls( entry['kolabfolderaclentry'], folder_path, True ) delivery_address_attribute = self.config_get('sharedfolder_delivery_address_attribute') if entry.has_key(delivery_address_attribute) and \ not entry[delivery_address_attribute] == None: self.imap.set_acl(folder_path, 'anyone', '+p') #if server == None: #self.entry_set_attribute(mailserver_attribute, server) def _change_none_user(self, entry, change): """ A user entry as part of the initial search result set. """ mailserver_attribute = self.config_get('mailserver_attribute') if mailserver_attribute == None: mailserver_attribute = 'mailhost' mailserver_attribute = mailserver_attribute.lower() result_attribute = conf.get('cyrus-sasl', 'result_attribute') if result_attribute == None: result_attribute = 'mail' result_attribute = result_attribute.lower() old_canon_attr = None _entry = cache.get_entry(self.domain, entry, update=False) if not _entry == None and _entry.__dict__.has_key('result_attribute') and not _entry.result_attribute == '': old_canon_attr = _entry.result_attribute entry_changes = self.recipient_policy(entry) if entry.has_key(result_attribute) and entry_changes.has_key(result_attribute): if not entry[result_attribute] == entry_changes[result_attribute]: old_canon_attr = entry[result_attribute] log.debug( _("Result from recipient policy: %r") % (entry_changes), level=8 ) if entry_changes.has_key(result_attribute) and not old_canon_attr == None: if not entry_changes[result_attribute] == old_canon_attr: self.imap.user_mailbox_rename( old_canon_attr, entry_changes[result_attribute] ) for key in entry_changes.keys(): entry[key] = entry_changes[key] self.set_entry_attribute(entry, key, entry[key]) cache.get_entry(self.domain, entry) self.imap.connect(domain=self.domain) server = None if not entry.has_key(mailserver_attribute): entry[mailserver_attribute] = self.get_entry_attribute(entry, mailserver_attribute) if entry[mailserver_attribute] == "" or entry[mailserver_attribute] == None: server = None else: server = entry[mailserver_attribute].lower() if entry.has_key(result_attribute) and \ not entry.has_key(result_attribute) == None: if not self.imap.user_mailbox_exists(entry[result_attribute]): folder = self.imap.user_mailbox_create(entry[result_attribute], server=server) server = self.imap.user_mailbox_server(folder) else: folder = "user%s%s" % ( self.imap.get_separator(), entry[result_attribute] ) server = self.imap.user_mailbox_server(folder) self.user_quota(entry, folder) mailserver_attr = self.config_get('mailserver_attribute') if not entry.has_key(mailserver_attr): self.set_entry_attribute(entry, mailserver_attr, server) else: if not entry[mailserver_attr] == server: # TODO: Should actually transfer mailbox self.set_entry_attribute(entry, mailserver_attr, server) else: log.warning( _("Kolab user %s does not have a result attribute %r") % ( entry['id'], result_attribute ) ) def _disconnect(self): del self.ldap del self.ldap_priv self.ldap = None self.ldap_priv = None self.bind = None def _domain_naming_context(self, domain): self._bind() # The list of naming contexts in the LDAP server attrs = self.get_entry_attributes("", ['namingContexts']) naming_contexts = attrs['namingcontexts'] if isinstance(naming_contexts, basestring): naming_contexts = [ naming_contexts ] log.debug( _("Naming contexts found: %r") % (naming_contexts), level=8 ) self._kolab_domain_root_dn(domain) log.debug( _("Domains/Root DNs found: %r") % ( self.domain_rootdns ), level=8 ) # If we have a 1:1 match, continue as planned for naming_context in naming_contexts: if self.domain_rootdns[domain].endswith(naming_context): return naming_context def _primary_domain_for_naming_context(self, naming_context): self._bind() _domain = '.'.join(naming_context.split(',dc='))[3:] _naming_context = self._kolab_domain_root_dn(_domain) if naming_context == _naming_context: return _domain def _entry_dict(self, value): """ Tests if 'value' is a valid entry dictionary with a DN contained within key 'dn'. Returns True or False """ if isinstance(value, dict): if value.has_key('dn'): return True return False def _entry_dn(self, value): """ Tests if 'value' is a valid DN. Returns True or False """ # Only basestrings can be DNs if not isinstance(value, basestring): return False try: import ldap.dn ldap_dn = ldap.dn.explode_dn(value) except ldap.DECODING_ERROR: # This is not a DN. return False return True def _entry_type(self, entry_id): """ Return the type of object for an entry. """ self._bind() entry_dn = self.entry_dn(entry_id) config_base_dn = self.config_get('base_dn') ldap_base_dn = self._kolab_domain_root_dn(self.domain) if not ldap_base_dn == None and not ldap_base_dn == config_base_dn: base_dn = ldap_base_dn else: base_dn = config_base_dn for _type in ['user', 'group', 'sharedfolder']: __filter = self.config_get('kolab_%s_filter' % (_type)) if __filter == None: __filter = self.config_get('%s_filter' % (_type)) if not __filter == None: try: result = self._regular_search(entry_dn, filterstr=__filter) except: result = self._regular_search( base_dn, filterstr="(%s=%s)" %( self.config_get('unique_attribute'), entry_id['id']) ) if not result: continue else: return _type def _find_user_dn(self, login, kolabuser=False): """ Find the distinguished name (DN) for a (Kolab) user entry in LDAP. """ conf_prefix = 'kolab_' if kolabuser else '' domain_root_dn = self._kolab_domain_root_dn(self.domain) user_base_dn = self.config_get(conf_prefix + 'user_base_dn') if user_base_dn == None: user_base_dn = self.config_get('base_dn') auth_attrs = self.config_get_list('auth_attributes') auth_search_filter = [ '(|' ] for auth_attr in auth_attrs: auth_search_filter.append('(%s=%s)' % (auth_attr,login)) if not '@' in login: auth_search_filter.append( '(%s=%s@%s)' % ( auth_attr, login, self.domain ) ) auth_search_filter.append(')') auth_search_filter = ''.join(auth_search_filter) user_filter = self.config_get(conf_prefix + 'user_filter') search_filter = "(&%s%s)" % ( auth_search_filter, user_filter ) _results = self._search( user_base_dn, filterstr=search_filter, attrlist=[ 'dn' ], override_search='_regular_search' ) if len(_results) == 1: (_user_dn, _user_attrs) = _results[0] else: # Retry to find the user_dn with just uid=%s against the root_dn, # if the login is not fully qualified if len(login.split('@')) < 2: search_filter = "(uid=%s)" % (login) _results = self._search( domain, filterstr=search_filter, attrlist=[ 'dn' ] ) if len(_results) == 1: (_user_dn, _user_attrs) = _results[0] else: # Overall fail return False else: return False return _user_dn def _kolab_domain_root_dn(self, domain): log.debug(_("Searching root dn for domain %r") % (domain), level=8) if not hasattr(self, 'domain_rootdns'): self.domain_rootdns = {} if self.domain_rootdns.has_key(domain): log.debug(_("Returning from cache: %r") % (self.domain_rootdns[domain]), level=8) return self.domain_rootdns[domain] self._bind() log.debug(_("Finding domain root dn for domain %s") % (domain), level=8) domain_base_dn = conf.get('ldap', 'domain_base_dn', quiet=True) domain_filter = conf.get('ldap', 'domain_filter') if not domain_filter == None: if not domain == None: domain_filter = domain_filter.replace('*', domain) if not domain_base_dn == "": _results = self._search( domain_base_dn, ldap.SCOPE_SUBTREE, domain_filter, override_search='_regular_search' ) domains = [] for _domain in _results: (domain_dn, _domain_attrs) = _domain domain_rootdn_attribute = conf.get( 'ldap', 'domain_rootdn_attribute' ) _domain_attrs = utils.normalize(_domain_attrs) if _domain_attrs.has_key(domain_rootdn_attribute): log.debug(_("Setting domain root dn from LDAP for domain %r: %r") % (domain, _domain_attrs[domain_rootdn_attribute]), level=8) self.domain_rootdns[domain] = _domain_attrs[domain_rootdn_attribute] return _domain_attrs[domain_rootdn_attribute] else: domain_name_attribute = self.config_get('domain_name_attribute') if domain_name_attribute == None: domain_name_attribute = 'associateddomain' if isinstance(_domain_attrs[domain_name_attribute], list): domain = _domain_attrs[domain_name_attribute][0] else: domain = _domain_attrs[domain_name_attribute] else: if conf.has_option('ldap', 'base_dn'): return conf.get('ldap', 'base_dn') self.domain_rootdns[domain] = utils.standard_root_dn(domain) return self.domain_rootdns[domain] def _kolab_filter(self): """ Compose a filter using the relevant settings from configuration. """ _filter = "(|" for _type in ['user', 'group', 'resource', 'sharedfolder']: __filter = self.config_get('kolab_%s_filter' % (_type)) if __filter == None: __filter = self.config_get('%s_filter' % (_type)) if not __filter == None: _filter = "%s%s" % (_filter,__filter) _filter = "%s)" % (_filter) return _filter def _list_domains(self, domain=None): """ Find the domains related to this Kolab setup, and return a list of DNS domain names. Returns a list of tuples, each tuple containing the primary domain name and a list of secondary domain names. This function should only be called by the primary instance of Auth. """ log.debug(_("Listing domains..."), level=8) self.connect() self._bind() domain_base_dn = conf.get('ldap', 'domain_base_dn', quiet=True) if domain_base_dn == "": # No domains are to be found in LDAP, return an empty list. # Note that the Auth() base itself handles this case. return [] # If we haven't returned already, let's continue searching domain_filter = conf.get('ldap', 'domain_filter') if not domain == None: domain_filter = domain_filter.replace('*', domain) if domain_base_dn == None or domain_filter == None: return [] dna = self.config_get('domain_name_attribute') if dna == None: dna = 'associateddomain' try: _search = self._search( domain_base_dn, ldap.SCOPE_SUBTREE, domain_filter, # TODO: Where we use associateddomain is actually # configurable [ dna ], override_search='_regular_search' ) except: return [] domains = [] for domain_dn, domain_attrs in _search: primary_domain = None secondary_domains = [] domain_attrs = utils.normalize(domain_attrs) # TODO: Where we use associateddomain is actually configurable if type(domain_attrs[dna]) == list: primary_domain = domain_attrs[dna].pop(0).lower() secondary_domains = [x.lower() for x in domain_attrs[dna]] else: primary_domain = domain_attrs[dna].lower() domains.append((primary_domain,secondary_domains)) return domains def _synchronize_callback(self, *args, **kw): """ Determine the characteristics of the callback being placed, and what data is contained within *args and **kw exactly. The exact form and shape of the feedback very much depends on the supportedControl used to even get the data. """ log.debug( "auth.ldap.LDAP._synchronize_callback(args %r, kw %r)" % ( args, kw ), - level=9 + level=8 ) # Typical for Persistent Change Control EntryChangeNotification if kw.has_key('change_type'): change_type = None change_dict = { 'change_type': kw['change_type'], 'previous_dn': kw['previous_dn'], 'change_number': kw['change_number'], 'dn': kw['dn'] } entry = utils.normalize(kw['entry']) entry['dn'] = kw['dn'] unique_attr = self.config_get('unique_attribute') entry['id'] = entry[unique_attr] try: entry['type'] = self._entry_type(entry) except: entry['type'] = None log.debug(_("Entry type: %s") % (entry['type']), level=8) if change_dict['change_type'] == None: # This entry was in the start result set eval("self._change_none_%s(entry, change_dict)" % (entry['type'])) else: if isinstance(change_dict['change_type'], int): change = psearch.CHANGE_TYPES_STR[change_dict['change_type']] change = change.lower() else: change = change_dict['change_type'] # See if we can find the cache entry - this way we can get to # the value of a (former, on a deleted entry) result_attribute result_attribute = conf.get('cyrus-sasl', 'result_attribute') if not entry.has_key(result_attribute): cache_entry = cache.get_entry(self.domain, entry, update=False) if hasattr(cache_entry, 'result_attribute') and change == 'delete': entry[result_attribute] = cache_entry.result_attribute eval( "self._change_%s_%s(entry, change_dict)" % ( change, entry['type'] ) ) # Typical for Paged Results Control elif kw.has_key('entry') and isinstance(kw['entry'], list): for entry_dn,entry_attrs in kw['entry']: # This is a referral if entry_dn == None: continue entry = { 'dn': entry_dn } entry_attrs = utils.normalize(entry_attrs) for attr in entry_attrs.keys(): entry[attr.lower()] = entry_attrs[attr] unique_attr = self.config_get('unique_attribute') entry['id'] = entry[unique_attr] try: entry['type'] = self._entry_type(entry) except: entry['type'] = "unknown" log.debug(_("Entry type: %s") % (entry['type']), level=8) eval("self._change_none_%s(entry, None)" % (entry['type'])) # result_attribute = conf.get('cyrus-sasl', 'result_attribute') # # rcpt_addrs = self.recipient_policy(entry) # -# log.debug(_("Recipient Addresses: %r") % (rcpt_addrs), level=9) +# log.debug(_("Recipient Addresses: %r") % (rcpt_addrs), level=8) # # for key in rcpt_addrs.keys(): # entry[key] = rcpt_addrs[key] # # cache.get_entry(self.domain, entry) # # self.imap.connect(domain=self.domain) # # if not self.imap.user_mailbox_exists(entry[result_attribute]): # folder = self.imap.user_mailbox_create( # entry[result_attribute] # ) # # server = self.imap.user_mailbox_server(folder) ### ### Backend search functions ### def _persistent_search(self, base_dn, scope=ldap.SCOPE_SUBTREE, filterstr="(objectClass=*)", attrlist=None, attrsonly=0, timeout=-1, callback=False, primary_domain=None, secondary_domains=[] ): _results = [] psearch_server_controls = [] psearch_server_controls.append(psearch.PersistentSearchControl( criticality=True, changeTypes=[ 'add', 'delete', 'modify', 'modDN' ], changesOnly=False, returnECs=True ) ) _search = self.ldap.search_ext( base_dn, scope=scope, filterstr=filterstr, attrlist=attrlist, attrsonly=attrsonly, timeout=timeout, serverctrls=psearch_server_controls ) ecnc = psearch.EntryChangeNotificationControl while True: res_type,res_data,res_msgid,_None,_None,_None = self.ldap.result4( _search, all=0, add_ctrls=1, add_intermediates=1, resp_ctrl_classes={ecnc.controlType:ecnc} ) change_type = None previous_dn = None change_number = None for dn,entry,srv_ctrls in res_data: log.debug(_("LDAP Search Result Data Entry:"), level=8) log.debug(" DN: %r" % (dn), level=8) log.debug(" Entry: %r" % (entry), level=8) ecn_ctrls = [ c for c in srv_ctrls if c.controlType == ecnc.controlType ] if ecn_ctrls: change_type = ecn_ctrls[0].changeType previous_dn = ecn_ctrls[0].previousDN change_number = ecn_ctrls[0].changeNumber change_type_desc = psearch.CHANGE_TYPES_STR[change_type] log.debug( _("Entry Change Notification attributes:"), level=8 ) log.debug( " " + _("Change Type: %r (%r)") % ( change_type, change_type_desc ), level=8 ) log.debug( " " + _("Previous DN: %r") % (previous_dn), level=8 ) if callback: callback( dn=dn, entry=entry, previous_dn=previous_dn, change_type=change_type, change_number=change_number, primary_domain=primary_domain, secondary_domains=secondary_domains ) def _paged_search(self, base_dn, scope=ldap.SCOPE_SUBTREE, filterstr="(objectClass=*)", attrlist=None, attrsonly=0, timeout=-1, callback=False, primary_domain=None, secondary_domains=[] ): page_size = 500 critical = True _results = [] server_page_control = SimplePagedResultsControl(page_size=page_size) _search = self.ldap.search_ext( base_dn, scope=scope, filterstr=filterstr, attrlist=attrlist, attrsonly=attrsonly, serverctrls=[server_page_control] ) pages = 0 while True: pages += 1 try: ( _result_type, _result_data, _result_msgid, _result_controls ) = self.ldap.result3(_search) except ldap.NO_SUCH_OBJECT, e: log.warning( _("Object %s searched no longer exists") % (base_dn) ) break if callback: callback(entry=_result_data) _results.extend(_result_data) if (pages % 2) == 0: log.debug(_("%d results...") % (len(_results))) pctrls = [ c for c in _result_controls if c.controlType == LDAP_CONTROL_PAGED_RESULTS ] if pctrls: if hasattr(pctrls[0], 'size'): size = pctrls[0].size cookie = pctrls[0].cookie else: size, cookie = pctrls[0].controlValue if cookie: server_page_control.cookie = cookie _search = self.ldap.search_ext( base_dn, scope=scope, filterstr=filterstr, attrlist=attrlist, attrsonly=attrsonly, serverctrls=[server_page_control] ) else: # TODO: Error out more verbose break else: # TODO: Error out more verbose print "Warning: Server ignores RFC 2696 control." break return _results def _vlv_search(self, base_dn, scope=ldap.SCOPE_SUBTREE, filterstr="(objectClass=*)", attrlist=None, attrsonly=0, timeout=-1, callback=False, primary_domain=None, secondary_domains=[] ): pass def _sync_repl(self, base_dn, scope=ldap.SCOPE_SUBTREE, filterstr="(objectClass=*)", attrlist=None, attrsonly=0, timeout=-1, callback=False, primary_domain=None, secondary_domains=[] ): import ldapurl import syncrepl ldap_url = ldapurl.LDAPUrl(self.config_get('ldap_uri')) ldap_sync_conn = syncrepl.DNSync( '/var/lib/kolab/syncrepl_%s.db' % (self.domain), ldap_url.initializeUrl(), trace_level=2, + trace_file=pykolab.logger.StderrToLogger(log), callback=self._synchronize_callback ) bind_dn = self.config_get('bind_dn') bind_pw = self.config_get('bind_pw') ldap_sync_conn.simple_bind_s(bind_dn, bind_pw) msgid = ldap_sync_conn.syncrepl_search( base_dn, scope, mode='refreshAndPersist', filterstr=filterstr, attrlist=attrlist, ) try: # Here's where returns need to be taken into account... while ldap_sync_conn.syncrepl_poll(all=1, msgid=msgid): pass except KeyboardInterrupt: pass def _regular_search(self, base_dn, scope=ldap.SCOPE_SUBTREE, filterstr="(objectClass=*)", attrlist=None, attrsonly=0, timeout=-1, callback=False, primary_domain=None, secondary_domains=[] ): log.debug(_("Searching with filter %r") % (filterstr), level=8) _search = self.ldap.search( base_dn, scope=scope, filterstr=filterstr, attrlist=attrlist, attrsonly=attrsonly ) _results = [] _result_type = None while not _result_type == ldap.RES_SEARCH_RESULT: (_result_type, _result) = self.ldap.result(_search, False, 0) if not _result == None: for result in _result: _results.append(result) return _results def _search(self, base_dn, scope=ldap.SCOPE_SUBTREE, filterstr="(objectClass=*)", attrlist=None, attrsonly=0, timeout=-1, override_search=False, callback=False, primary_domain=None, secondary_domains=[] ): """ Search LDAP. Use the priority ordered SUPPORTED_LDAP_CONTROLS and use the first one supported. """ supported_controls = conf.get_list('ldap', 'supported_controls') if not supported_controls == None and not len(supported_controls) < 1: for control_num in [(int)(x) for x in supported_controls]: self.ldap.supported_controls.append( SUPPORTED_LDAP_CONTROLS[control_num]['func'] ) if len(self.ldap.supported_controls) < 1: for control_num in SUPPORTED_LDAP_CONTROLS.keys(): log.debug( _("Checking for support for %s on %s") % ( SUPPORTED_LDAP_CONTROLS[control_num]['desc'], self.domain ), level=8 ) _search = self.ldap.search_s( '', scope=ldap.SCOPE_BASE, attrlist=['supportedControl'] ) for (_result,_supported_controls) in _search: supported_controls = _supported_controls.values()[0] for control_num in SUPPORTED_LDAP_CONTROLS.keys(): if SUPPORTED_LDAP_CONTROLS[control_num]['oid'] in \ supported_controls: log.debug(_("Found support for %s") % ( SUPPORTED_LDAP_CONTROLS[control_num]['desc'], ), level=8 ) self.ldap.supported_controls.append( SUPPORTED_LDAP_CONTROLS[control_num]['func'] ) _results = [] if not override_search == False: _use_ldap_controls = [ override_search ] else: _use_ldap_controls = self.ldap.supported_controls for supported_control in _use_ldap_controls: # Repeat the same supported control until # a failure (Exception) occurs that been # recognized as not an error related to the # supported control (such as ldap.SERVER_DOWN). failed_ok = False while not failed_ok: try: exec("""_results = self.%s( %r, scope=%r, filterstr=%r, attrlist=%r, attrsonly=%r, timeout=%r, callback=callback, primary_domain=%r, secondary_domains=%r )""" % ( supported_control, base_dn, scope, filterstr, attrlist, attrsonly, timeout, primary_domain, secondary_domains ) ) break except ldap.SERVER_DOWN, errmsg: log.error(_("LDAP server unavailable: %r") % (errmsg)) log.error(_("%s") % (traceback.format_exc())) log.error(_("-- reconnecting in 10 seconds.")) time.sleep(10) self.reconnect() except Exception, errmsg: failed_ok = True log.error(_("An error occured using %s: %r") % (supported_control, errmsg)) log.error(_("%s") % (traceback.format_exc())) continue return _results def _parse_acl(self, acl): """ Parse LDAP ACL specification for use in IMAP """ results = [] if acl is not None: if not isinstance(acl, list): acl = [ acl ] for acl_entry in acl: # entry already converted to IMAP format? if acl_entry[0] == "(": results.append(acl_entry) continue acl_access = acl_entry.split()[-1] acl_subject = acl_entry.split(', ') if len(acl_subject) > 1: acl_subject = ', '.join(acl_subject[:-1]) else: acl_subject = acl_entry.split()[0] results.append("(%r, %r)" % (acl_subject, acl_access)) return results diff --git a/pykolab/cli/sieve/cmd_refresh.py b/pykolab/cli/sieve/cmd_refresh.py index 3d6b1d9..b784eac 100644 --- a/pykolab/cli/sieve/cmd_refresh.py +++ b/pykolab/cli/sieve/cmd_refresh.py @@ -1,420 +1,420 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import pykolab from pykolab import utils from pykolab.auth import Auth from pykolab.cli import commands from pykolab.translate import _ log = pykolab.getLogger('pykolab.cli') conf = pykolab.getConf() import sys import time from urlparse import urlparse def __init__(): commands.register('refresh', execute, group='sieve', description=description()) def description(): return """Refresh a user's managed and contributed sieve scripts.""" def execute(*args, **kw): try: address = conf.cli_args.pop(0) except: address = utils.ask_question(_("Email Address")) auth = Auth() auth.connect() user = auth.find_recipient(address) # Get the main, default backend backend = conf.get('kolab', 'imap_backend') if len(address.split('@')) > 1: domain = address.split('@')[1] else: domain = conf.get('kolab', 'primary_domain') if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: uri = conf.get(backend, 'uri') hostname = None port = None result = urlparse(uri) if hasattr(result, 'hostname'): hostname = result.hostname else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') port = 4190 # Get the credentials admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') import sievelib.managesieve sieveclient = sievelib.managesieve.Client(hostname, port, conf.debuglevel > 8) sieveclient.connect(None, None, True) sieveclient._plain_authentication(admin_login, admin_password, address) sieveclient.authenticated = True result = sieveclient.listscripts() if result == None: active = None scripts = [] else: active, scripts = result log.debug(_("Found the following scripts for user %s: %s") % (address, ','.join(scripts)), level=8) log.debug(_("And the following script is active for user %s: %s") % (address, active), level=8) mgmt_required_extensions = [] mgmt_script = """# # MANAGEMENT # """ user = auth.get_entry_attributes(domain, user, ['*']) # # Vacation settings (a.k.a. Out of Office) # vacation_active = None vacation_text = None vacation_uce = None vacation_noreact_domains = None vacation_react_domains = None vacation_active_attr = conf.get('sieve', 'vacation_active_attr') vacation_text_attr = conf.get('sieve', 'vacation_text_attr') vacation_uce_attr = conf.get('sieve', 'vacation_uce_attr') vacation_noreact_domains_attr = conf.get('sieve', 'vacation_noreact_domains_attr') vacation_react_domains_attr = conf.get('sieve', 'vacation_react_domains_attr') if not vacation_text_attr == None: if user.has_key(vacation_active_attr): vacation_active = utils.true_or_false(user[vacation_active_attr]) else: vacation_active = False if user.has_key(vacation_text_attr): vacation_text = user[vacation_text_attr] else: vacation_active = False if user.has_key(vacation_uce_attr): vacation_uce = utils.true_or_false(user[vacation_uce_attr]) else: vacation_uce = False if user.has_key(vacation_react_domains_attr): if isinstance(user[vacation_react_domains_attr], list): vacation_react_domains = user[vacation_react_domains_attr] else: vacation_react_domains = [ user[vacation_react_domains_attr] ] else: if user.has_key(vacation_noreact_domains_attr): if isinstance(user[vacation_noreact_domains_attr], list): vacation_noreact_domains = user[vacation_noreact_domains_attr] else: vacation_noreact_domains = [ user[vacation_noreact_domains_attr] ] else: vacation_noreact_domains = [] # # Delivery to Folder # dtf_active_attr = conf.get('sieve', 'deliver_to_folder_active') if not dtf_active_attr == None: if user.has_key(dtf_active_attr): dtf_active = utils.true_or_false(user[dtf_active_attr]) else: dtf_active = False else: # TODO: Not necessarily de-activated, the *Active attributes are # not supposed to charge this - check the deliver_to_folder_attr # attribute value for a value. dtf_active = False if dtf_active: dtf_folder_name_attr = conf.get('sieve', 'deliver_to_folder_attr') if not dtf_folder_name_attr == None: if user.has_key(dtf_folder_name_attr): dtf_folder = user[dtf_folder_name_attr] else: log.warning(_("Delivery to folder active, but no folder name attribute available for user %r") % (user)) dtf_active = False else: log.error(_("Delivery to folder active, but no folder name attribute configured")) dtf_active = False # # Folder name to delivery spam to. # # Global or local. # sdf_filter = True sdf = conf.get('sieve', 'spam_global_folder') if sdf == None: sdf = conf.get('sieve', 'spam_personal_folder') if sdf == None: sdf_filter = False # # Mail forwarding # forward_active = None forward_addresses = [] forward_keepcopy = None forward_uce = None forward_active_attr = conf.get('sieve', 'forward_address_active') if not forward_active_attr == None: if user.has_key(forward_active_attr): forward_active = utils.true_or_false(user[forward_active_attr]) else: forward_active = False if not forward_active == False: forward_address_attr = conf.get('sieve', 'forward_address_attr') if user.has_key(forward_address_attr): if isinstance(user[forward_address_attr], basestring): forward_addresses = [ user[forward_address_attr] ] elif isinstance(user[forward_address_attr], str): forward_addresses = [ user[forward_address_attr] ] else: forward_addresses = user[forward_address_attr] if len(forward_addresses) == 0: forward_active = False forward_keepcopy_attr = conf.get('sieve', 'forward_keepcopy_active') if not forward_keepcopy_attr == None: if user.has_key(forward_keepcopy_attr): forward_keepcopy = utils.true_or_false(user[forward_keepcopy_attr]) else: forward_keepcopy = False forward_uce_attr = conf.get('sieve', 'forward_uce_active') if not forward_uce_attr == None: if user.has_key(forward_uce_attr): forward_uce = utils.true_or_false(user[forward_uce_attr]) else: forward_uce = False if vacation_active: mgmt_required_extensions.append('vacation') mgmt_required_extensions.append('envelope') if dtf_active: mgmt_required_extensions.append('fileinto') if forward_active and (len(forward_addresses) > 1 or forward_keepcopy): mgmt_required_extensions.append('copy') if sdf_filter: mgmt_required_extensions.append('fileinto') import sievelib.factory mgmt_script = sievelib.factory.FiltersSet("MANAGEMENT") for required_extension in mgmt_required_extensions: mgmt_script.require(required_extension) mgmt_script.require('fileinto') if vacation_active: if not vacation_react_domains == None and len(vacation_react_domains) > 0: mgmt_script.addfilter( 'vacation', [('envelope', ':domain', ":is", "from", vacation_react_domains)], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) elif not vacation_noreact_domains == None and len(vacation_noreact_domains) > 0: mgmt_script.addfilter( 'vacation', [('not', ('envelope', ':domain', ":is", "from", vacation_noreact_domains))], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) else: mgmt_script.addfilter( 'vacation', [('true',)], [ ( "vacation", ":days", 1, ":subject", "Out of Office", # ":handle", see http://tools.ietf.org/html/rfc5230#page-4 # ":mime", to indicate the reason is in fact MIME vacation_text ) ] ) if forward_active: forward_rules = [] # Principle can be demonstrated by: # # python -c "print ','.join(['a','b','c'][:-1])" # for forward_copy in forward_addresses[:-1]: forward_rules.append(("redirect", ":copy", forward_copy)) if forward_keepcopy: # Principle can be demonstrated by: # # python -c "print ','.join(['a','b','c'][-1])" # if forward_uce: rule_name = 'forward-uce-keepcopy' else: rule_name = 'forward-keepcopy' forward_rules.append(("redirect", ":copy", forward_addresses[-1])) else: if forward_uce: rule_name = 'forward-uce' else: rule_name = 'forward' forward_rules.append(("redirect", forward_addresses[-1])) forward_rules.append(("stop")) if forward_uce: mgmt_script.addfilter(rule_name, ['true'], forward_rules) else: # NOTE: Messages with no X-Spam-Status header need to be matched # too, and this does exactly that. mgmt_script.addfilter(rule_name, [("not", ("X-Spam-Status", ":matches", "Yes,*"))], forward_rules) if sdf_filter: mgmt_script.addfilter('spam_delivery_folder', [("X-Spam-Status", ":matches", "Yes,*")], [("fileinto", "INBOX/Spam"), ("stop")]) if dtf_active: mgmt_script.addfilter('delivery_to_folder', ['true'], [("fileinto", dtf_folder)]) mgmt_script = mgmt_script.__str__() - log.debug(_("MANAGEMENT script for user %s contents: %r") % (address,mgmt_script), level=9) + log.debug(_("MANAGEMENT script for user %s contents: %r") % (address,mgmt_script), level=8) result = sieveclient.putscript("MANAGEMENT", mgmt_script) if not result: log.error(_("Uploading script MANAGEMENT failed for user %s") % (address)) else: log.debug(_("Uploading script MANAGEMENT for user %s succeeded") % (address), level=8) user_script = """# # User # require ["include"]; """ for script in scripts: if not script in [ "MASTER", "MANAGEMENT", "USER" ]: log.debug(_("Including script %s in USER (for user %s)") % (script,address) ,level=8) user_script = """%s include :personal "%s"; """ % (user_script, script) result = sieveclient.putscript("USER", user_script) if not result: log.error(_("Uploading script USER failed for user %s") % (address)) else: log.debug(_("Uploading script USER for user %s succeeded") % (address), level=8) result = sieveclient.putscript("MASTER", """# # MASTER # # This file is authoritative for your system and MUST BE KEPT ACTIVE. # # Altering it is likely to render your account dysfunctional and may # be violating your organizational or corporate policies. # # For more information on the mechanism and the conventions behind # this script, see http://wiki.kolab.org/KEP:14 # require ["include"]; # OPTIONAL: Includes for all or a group of users # include :global "all-users"; # include :global "this-group-of-users"; # The script maintained by the general management system include :personal "MANAGEMENT"; # The script(s) maintained by one or more editors available to the user include :personal "USER"; """) if not result: log.error(_("Uploading script MASTER failed for user %s") % (address)) else: log.debug(_("Uploading script MASTER for user %s succeeded") % (address), level=8) sieveclient.setactive("MASTER") diff --git a/pykolab/imap/cyrus.py b/pykolab/imap/cyrus.py index 27f889b..9907c44 100644 --- a/pykolab/imap/cyrus.py +++ b/pykolab/imap/cyrus.py @@ -1,620 +1,631 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import cyruslib import sys import time from urlparse import urlparse import pykolab from pykolab import constants from pykolab.imap import IMAP from pykolab.translate import _ log = pykolab.getLogger('pykolab.imap') conf = pykolab.getConf() class Cyrus(cyruslib.CYRUS): """ Abstraction class for some common actions to do exclusively in Cyrus. For example, the following functions require the commands to be executed against the backend server if a murder is being used. - Setting quota - Renaming the top-level mailbox - Setting annotations """ setquota = cyruslib.CYRUS.sq def __init__(self, uri): """ Initialize this class, but do not connect yet. """ port = None result = urlparse(uri) if hasattr(result, 'hostname'): scheme = result.scheme hostname = result.hostname port = result.port else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') if not port: if scheme == 'imap': port = 143 else: port = 993 self.server = hostname self.uri = "%s://%s:%s" % (scheme, hostname, port) while 1: try: cyruslib.CYRUS.__init__(self, self.uri) break except cyruslib.CYRUSError: log.warning( _("Could not connect to Cyrus IMAP server %r") % ( self.uri ) ) time.sleep(10) if conf.debuglevel > 8: self.VERBOSE = True self.m.debug = 5 + sl = pykolab.logger.StderrToLogger(log) + # imaplib debug outputs everything to stderr. Redirect to Logger + sys.stderr = sl + # cyruslib debug outputs everything to LOGFD. Redirect to Logger + self.LOGFD = sl # Initialize our variables self.separator = self.SEP # Placeholder for known mailboxes on known servers self.mbox = {} def __del__(self): pass def connect(self, uri): """ Dummy connect function that checks if the server that we want to connect to is actually the server we are connected to. Uses pykolab.imap.IMAP.connect() in the background. """ port = None result = urlparse(uri) if hasattr(result, 'hostname'): scheme = result.scheme hostname = result.hostname port = result.port else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') if not port: if scheme == 'imap': port = 143 else: port = 993 if hostname == self.server: return imap = IMAP() imap.connect(uri=uri) if not self.SEP == self.separator: self.separator = self.SEP def login(self, *args, **kw): """ Login to the Cyrus IMAP server through cyruslib.CYRUS, but set our hierarchy separator. """ - cyruslib.CYRUS.login(self, *args, **kw) + try: + cyruslib.CYRUS.login(self, *args, **kw) + except cyruslib.CYRUSError, errmsg: + log.error("Login to Cyrus IMAP server failed: %r", errmsg) + except Exception, errmsg: + log.exception(errmsg) + self.separator = self.SEP try: self._id() except Exception, errmsg: pass log.debug( _("Continuing with separator: %r") % (self.separator), level=8 ) self.murder = False for capability in self.m.capabilities: if capability.startswith("MUPDATE="): log.debug( _("Detected we are running in a Murder topology"), level=8 ) self.murder = True if not self.murder: log.debug( _("This system is not part of a murder topology"), level=8 ) def find_mailfolder_server(self, mailfolder): annotations = {} _mailfolder = self.parse_mailfolder(mailfolder) prefix = _mailfolder['path_parts'][0] mbox = _mailfolder['path_parts'][1] if _mailfolder['domain'] is not None: mailfolder = "%s%s%s@%s" % ( prefix, self.separator, mbox, _mailfolder['domain'] ) # TODO: Workaround for undelete if len(self.lm(mailfolder)) < 1 and _mailfolder['hex_timestamp']: mailfolder = self.folder_utf7("DELETED/%s%s%s@%s" % ( self.separator.join(_mailfolder['path_parts']), self.separator, _mailfolder['hex_timestamp'], _mailfolder['domain']) ) # TODO: Murder capabilities may have been suppressed using Cyrus IMAP # configuration. if not self.murder: return self.server log.debug( _("Checking actual backend server for folder %s " + "through annotations") % ( mailfolder ), level=8 ) if self.mbox.has_key(mailfolder): log.debug( _( "Possibly reproducing the find " + "mailfolder server answer from " + "previously detected and stored " + "annotation value: %r" ) % ( self.mbox[mailfolder] ), level=8 ) if not self.mbox[mailfolder] == self.server: return self.mbox[mailfolder] max_tries = 20 num_try = 0 ann_path = "/vendor/cmu/cyrus-imapd/server" s_ann_path = "/shared%s" % (ann_path) while 1: num_try += 1 annotations = self._getannotation( '"%s"' % (mailfolder), ann_path ) if annotations.has_key(mailfolder): if annotations[mailfolder].has_key(s_ann_path): break if max_tries <= num_try: log.error( _("Could not get the annotations after %s tries.") % ( num_try ) ) annotations = { mailfolder: { s_ann_path: self.server } } break log.warning( _("No annotations for %s: %r") % ( mailfolder, annotations ) ) time.sleep(1) server = annotations[mailfolder][s_ann_path] self.mbox[mailfolder] = server log.debug( _("Server for INBOX folder %s is %s") % ( mailfolder, server ), level=8 ) return server def folder_utf7(self, folder): from pykolab import imap_utf7 return imap_utf7.encode(folder) def folder_utf8(self, folder): from pykolab import imap_utf7 return imap_utf7.decode(folder) def _id(self, identity=None): if identity is None: identity = '("name" "Python/Kolab" "version" "%s")' % (constants.__version__) typ, dat = self.m._simple_command('ID', identity) res, dat = self.m._untagged_response(typ, dat, 'ID') def _setquota(self, mailfolder, quota): """ Login to the actual backend server. """ server = self.find_mailfolder_server(mailfolder) self.connect(self.uri.replace(self.server, server)) log.debug( _("Setting quota for folder %s to %s") % ( mailfolder, quota ), level=8 ) try: self.m.setquota(mailfolder, quota) except: log.error( _("Could not set quota for mailfolder %s") % ( mailfolder ) ) def _rename(self, from_mailfolder, to_mailfolder, partition=None): """ Login to the actual backend server, then rename. """ server = self.find_mailfolder_server(from_mailfolder) self.connect(self.uri.replace(self.server, server)) if partition is not None: log.debug( _("Moving INBOX folder %s to %s on partition %s") % ( from_mailfolder, to_mailfolder, partition ), level=8 ) else: log.debug( _("Moving INBOX folder %s to %s") % ( from_mailfolder, to_mailfolder ), level=8 ) self.m.rename( self.folder_utf7(from_mailfolder), self.folder_utf7(to_mailfolder), partition ) def _getannotation(self, *args, **kw): return self.getannotation(*args, **kw) def _setannotation(self, mailfolder, annotation, value, shared=False): """ Login to the actual backend server, then set annotation. """ try: server = self.find_mailfolder_server(mailfolder) except: server = self.server log.debug( _("Setting annotation %s on folder %s") % ( annotation, mailfolder ), level=8 ) try: self.setannotation(mailfolder, annotation, value, shared) except cyruslib.CYRUSError, errmsg: log.error( _("Could not set annotation %r on mail folder %r: %r") % ( annotation, mailfolder, errmsg ) ) def _xfer(self, mailfolder, current_server, new_server): self.connect(self.uri.replace(self.server, current_server)) log.debug( _("Transferring folder %s from %s to %s") % ( mailfolder, current_server, new_server ), level=8 ) self.xfer(mailfolder, new_server) def undelete_mailfolder( self, mailfolder, to_mailfolder=None, recursive=True ): """ Login to the actual backend server, then "undelete" the mailfolder. 'mailfolder' may be a string representing either of the following two options; - the fully qualified pathof the deleted folder in its current location, such as, for a deleted INBOX folder originally known as "user/userid[@domain]"; "DELETED/user/userid/hex[@domain]" - the original folder name, such as; "user/userid[@domain]" 'to_mailfolder' may be the target folder to "undelete" the deleted folder to. If not specified, the original folder name is used. """ # Placeholder for folders we have recovered already. target_folders = [] mailfolder = self.parse_mailfolder(mailfolder) undelete_folders = self._find_deleted_folder(mailfolder) if to_mailfolder is not None: target_mbox = self.parse_mailfolder(to_mailfolder) else: target_mbox = mailfolder for undelete_folder in undelete_folders: undelete_mbox = self.parse_mailfolder(undelete_folder) prefix = undelete_mbox['path_parts'].pop(0) mbox = undelete_mbox['path_parts'].pop(0) if to_mailfolder is None: target_folder = self.separator.join([prefix, mbox]) else: target_folder = self.separator.join(target_mbox['path_parts']) if to_mailfolder is not None: target_folder = "%s%s%s" % ( target_folder, self.separator, mbox ) if not len(undelete_mbox['path_parts']) == 0: target_folder = "%s%s%s" % ( target_folder, self.separator, self.separator.join(undelete_mbox['path_parts']) ) if target_folder in target_folders: target_folder = "%s%s%s" % ( target_folder, self.separator, undelete_mbox['hex_timestamp'] ) target_folders.append(target_folder) if target_mbox['domain'] is not None: target_folder = "%s@%s" % ( target_folder, target_mbox['domain'] ) log.info( _("Undeleting %s to %s") % ( undelete_folder, target_folder ) ) target_server = self.find_mailfolder_server(target_folder) source_server = self.find_mailfolder_server(undelete_folder) if hasattr(conf, 'dry_run') and not conf.dry_run: undelete_folder = self.folder_utf7(undelete_folder) target_folder = self.folder_utf7(target_folder) if not target_server == source_server: self.xfer(undelete_folder, target_server) self.rename(undelete_folder, target_folder) else: if not target_server == source_server: print >> sys.stdout, \ _("Would have transferred %s from %s to %s") % ( undelete_folder, source_server, target_server ) print >> sys.stdout, \ _("Would have renamed %s to %s") % ( undelete_folder, target_folder ) def parse_mailfolder(self, mailfolder): """ Parse a mailfolder name to it's parts. Takes a fully qualified mailfolder or mailfolder sub-folder. """ mbox = { 'domain': None } if len(mailfolder.split('/')) > 1: self.separator = '/' # Split off the virtual domain identifier, if any if len(mailfolder.split('@')) > 1: mbox['domain'] = mailfolder.split('@')[1] mbox['path_parts'] = mailfolder.split('@')[0].split(self.separator) else: mbox['path_parts'] = mailfolder.split(self.separator) # See if the path that has been specified is the current location for # the deleted folder, or the original location, we have to find the # deleted folder for. if not mbox['path_parts'][0] in ['user', 'shared']: deleted_prefix = mbox['path_parts'].pop(0) # See if the hexadecimal timestamp is actually hexadecimal. # This prevents "DELETED/user/userid/Sent", but not # "DELETED/user/userid/FFFFFF" from being specified. try: hexstamp = mbox['path_parts'][(len(mbox['path_parts'])-1)] epoch = int(hexstamp, 16) try: timestamp = time.asctime(time.gmtime(epoch)) except: return None except: return None # Verify that the input for the deleted folder is actually a # deleted folder. verify_folder_search = "%(dp)s%(sep)s%(mailfolder)s" % { 'dp': deleted_prefix, 'sep': self.separator, 'mailfolder': self.separator.join(mbox['path_parts']) } if mbox['domain'] is not None: verify_folder_search = "%s@%s" % ( verify_folder_search, mbox['domain'] ) if ' ' in verify_folder_search: folders = self.lm( '"%s"' % self.folder_utf7(verify_folder_search) ) else: folders = self.lm(self.folder_utf7(verify_folder_search)) # NOTE: Case also covered is valid hexadecimal folders; won't be # the actual check as intended, but doesn't give you anyone else's # data unless... See the following: # # TODO: Case not covered is usernames that are hexadecimal. # # We could probably attempt to convert the int(hex) into a # time.gmtime(), but it still would not cover all cases. # # If no folders were found... well... then there you go. if len(folders) < 1: return None # Pop off the hex timestamp, which turned out to be valid mbox['hex_timestamp'] = mbox['path_parts'].pop() return mbox def _find_deleted_folder(self, mbox): """ Give me the parts that are in an original mailfolder name and I'll find the deleted folder name. TODO: It finds virtdomain folders for non-virtdomain searches. """ deleted_folder_search = "%(deleted_prefix)s%(separator)s%(mailfolder)s%(separator)s*" % { # TODO: The prefix used is configurable 'deleted_prefix': "DELETED", 'mailfolder': self.separator.join(mbox['path_parts']), 'separator': self.separator, } if mbox['domain'] is not None: deleted_folder_search = "%s@%s" % ( deleted_folder_search, mbox['domain'] ) folders = self.lm(self.folder_utf7(deleted_folder_search)) # The folders we have found at this stage include virtdomain folders. # # For example, having searched for user/userid, it will also find # user/userid@example.org # # Here, we explicitly remove any virtdomain folders. if mbox['domain'] is None: _folders = [] for folder in folders: if len(folder.split('@')) < 2: _folders.append(folder) folders = _folders return [self.folder_utf8(x) for x in folders] diff --git a/pykolab/itip/__init__.py b/pykolab/itip/__init__.py index f08b32b..0172c09 100644 --- a/pykolab/itip/__init__.py +++ b/pykolab/itip/__init__.py @@ -1,384 +1,384 @@ import icalendar import pykolab import traceback import kolabformat import re from pykolab.xml import to_dt from pykolab.xml import event_from_ical from pykolab.xml import todo_from_ical from pykolab.xml import participant_status_label from pykolab.translate import _ from tzlocal import windows_tz log = pykolab.getLogger('pykolab.wallace') def events_from_message(message, methods=None): return objects_from_message(message, ["VEVENT"], methods) def todos_from_message(message, methods=None): return objects_from_message(message, ["VTODO"], methods) def objects_from_message(message, objnames, methods=None): """ Obtain the iTip payload from email.message """ # Placeholder for any itip_objects found in the message. itip_objects = [] seen_uids = [] # iTip methods we are actually interested in. Other methods will be ignored. if methods is None: methods = [ "REQUEST", "CANCEL" ] # Are all iTip messages multipart? No! RFC 6047, section 2.4 states "A # MIME body part containing content information that conforms to this # document MUST have (...)" but does not state whether an iTip message must # therefore also be multipart. # Check each part for part in message.walk(): # The iTip part MUST be Content-Type: text/calendar (RFC 6047, section 2.4) # But in real word, other mime-types are used as well if part.get_content_type() in [ "text/calendar", "text/x-vcalendar", "application/ics" ]: if not str(part.get_param('method')).upper() in methods: log.info(_("Method %r not really interesting for us.") % (part.get_param('method'))) continue # Get the itip_payload itip_payload = part.get_payload(decode=True) - log.debug(_("Raw iTip payload (%r): %r") % (part.get_param('charset'), itip_payload), level=9) + log.debug(_("Raw iTip payload (%r): %r") % (part.get_param('charset'), itip_payload), level=8) # Convert unsupported timezones, etc. itip_payload = _convert_itip_payload(itip_payload) # Python iCalendar prior to 3.0 uses "from_string". if hasattr(icalendar.Calendar, 'from_ical'): cal = icalendar.Calendar.from_ical(itip_payload) elif hasattr(icalendar.Calendar, 'from_string'): cal = icalendar.Calendar.from_string(itip_payload) # If we can't read it, we're out else: log.error(_("Could not read iTip from message.")) return [] for c in cal.walk(): if c.name in objnames: itip = {} if c['uid'] in seen_uids: - log.debug(_("Duplicate iTip object: %s") % (c['uid']), level=9) + log.debug(_("Duplicate iTip object: %s") % (c['uid']), level=8) continue # From the event, take the following properties: # # - method # - uid # - sequence # - start # - end (if any) # - duration (if any) # - organizer # - attendees (if any) # - resources (if any) # itip['type'] = 'task' if c.name == 'VTODO' else 'event' itip['uid'] = str(c['uid']) itip['method'] = str(cal['method']).upper() itip['sequence'] = int(c['sequence']) if c.has_key('sequence') else 0 itip['recurrence-id'] = c['recurrence-id'].dt if c.has_key('recurrence-id') and hasattr(c['recurrence-id'], 'dt') else None if c.has_key('dtstart'): itip['start'] = c['dtstart'].dt elif itip['type'] == 'event': log.error(_("iTip event without a start")) continue if c.has_key('dtend'): itip['end'] = c['dtend'].dt if c.has_key('duration'): itip['duration'] = c['duration'].dt itip['end'] = itip['start'] + c['duration'].dt itip['organizer'] = c['organizer'] itip['attendees'] = c['attendee'] if itip.has_key('attendees') and not isinstance(itip['attendees'], list): itip['attendees'] = [c['attendee']] if c.has_key('resources'): itip['resources'] = c['resources'] itip['raw'] = itip_payload try: # distinguish event and todo here if itip['type'] == 'task': itip['xml'] = todo_from_ical(c, itip_payload) else: itip['xml'] = event_from_ical(c, itip_payload) except Exception, e: log.error("event|todo_from_ical() exception: %r; iCal: %s" % (e, itip_payload)) continue itip_objects.append(itip) seen_uids.append(c['uid']) # end if c.name in objnames # end for c in cal.walk() # end if part.get_content_type() == "text/calendar" # end for part in message.walk() if not len(itip_objects) and not message.is_multipart(): log.debug(_("Message is not an iTip message (non-multipart message)"), level=5) return itip_objects def check_event_conflict(kolab_event, itip_event): """ Determine whether the given kolab event conflicts with the given itip event """ conflict = False # don't consider conflict with myself if kolab_event.uid == itip_event['uid']: return conflict # don't consider conflict if event has TRANSP:TRANSPARENT if _is_transparent(kolab_event): return conflict if _is_transparent(itip_event['xml']): return conflict _es = to_dt(kolab_event.get_start()) _ee = to_dt(kolab_event.get_ical_dtend()) # use iCal style end date: next day for all-day events _ev = kolab_event _ei = 0 _is = to_dt(itip_event['start']) _ie = to_dt(itip_event['end']) _iv = itip_event['xml'] _ii = 0 # Escape looping through anything if neither of the events is recurring. if not itip_event['xml'].is_recurring() and not kolab_event.is_recurring(): return check_date_conflict(_es, _ee, _is, _ie) loop = 0 done = False # naive loops to check for collisions in (recurring) events # TODO: compare recurrence rules directly (e.g. matching time slot or weekday or monthday) while not conflict and not done: loop += 1 # Scroll forward the kolab event recurrence until we're in the prime # spot. We choose to start with the Kolab event because that is likely # the older one. if _ee < _is: while _ee < _is and _es is not None and kolab_event.is_recurring(): log.debug("Attempt to move forward kolab event recurrence from %s closer to %s" % (_ee, _is), level=8) __es = to_dt(kolab_event.get_next_occurence(_es)) if not __es is None: _es = __es _ee = to_dt(kolab_event.get_occurence_end_date(_es)) else: done = True break # Scroll forward the itip event recurrence until we're in the # prime spot, this time with the iTip event. elif _ie < _es: while _ie < _es and _is is not None and itip_event['xml'].is_recurring(): log.debug("Attempt to move forward itip event recurrence from %s closer to %s" % (_ie, _es), level=8) __is = to_dt(itip_event['xml'].get_next_occurence(_is)) if not __is is None: _is = __is _ie = to_dt(itip_event['xml'].get_occurence_end_date(_is)) else: done = True break # Now that we have some events somewhere in the same neighborhood... conflict = check_date_conflict(_es, _ee, _is, _ie) log.debug("* Comparing itip at %s/%s with kolab at %s/%s: %r (%d)" % (_is, _ie, _es, _ee, conflict, loop), level=8) if not conflict: if kolab_event.is_recurring() and itip_event['xml'].is_recurring(): if not kolab_event.has_exceptions() and not itip_event['xml'].has_exceptions(): log.debug("No conflict, both recurring, but neither with exceptions", level=8) done = True break _is = to_dt(itip_event['xml'].get_next_occurence(_is)) if _is is not None: _ie = to_dt(itip_event['xml'].get_occurence_end_date(_is)) else: done = True return conflict def _is_transparent(event): return event.get_transparency() or event.get_status() == kolabformat.StatusCancelled def _convert_itip_payload(itip): matchlist = re.findall("^((DTSTART|DTEND|DUE|EXDATE|COMPLETED)[:;][^\n]+)$", itip, re.MULTILINE) for match in matchlist: match = match[0] search = re.search(";TZID=([^:;]+)", match) if search: tzorig = tzdest = search.group(1).replace('"', '') # timezone in Olson-database format, nothing to convert if re.match("[a-zA-Z]+/[a-zA-Z0-9_+-]+", tzorig): continue # convert timezone from windows format to Olson if tzorig in windows_tz.win_tz: tzdest = windows_tz.win_tz[tzorig] # @TODO: Should be prefer server time if it has the same offset? # replace old with new timezone name if tzorig != tzdest: replace = match.replace(search.group(0), ";TZID=" + tzdest) itip = itip.replace("\n" + match, "\n" + replace) return itip def check_date_conflict(_es, _ee, _is, _ie): """ Check the given event start/end dates for conflicts """ conflict = False # TODO: add margin for all-day dates (+13h; -12h) if _es < _is: if _es <= _ie: if _ee <= _is: conflict = False else: conflict = True else: conflict = True elif _es == _is: conflict = True else: # _es > _is if _es < _ie: conflict = True else: conflict = False return conflict def send_reply(from_address, itip_events, response_text, subject=None): """ Send the given iCal events as a valid iTip REPLY to the organizer. """ import smtplib conf = pykolab.getConf() smtp = None if isinstance(itip_events, dict): itip_events = [ itip_events ] for itip_event in itip_events: attendee = itip_event['xml'].get_attendee_by_email(from_address) participant_status = itip_event['xml'].get_ical_attendee_participant_status(attendee) log.debug(_("Send iTip reply %s for %s %r") % (participant_status, itip_event['xml'].type, itip_event['xml'].uid), level=8) event_summary = itip_event['xml'].get_summary() message_text = response_text % { 'summary':event_summary, 'status':participant_status_label(participant_status), 'name':attendee.get_name() } if subject is not None: subject = subject % { 'summary':event_summary, 'status':participant_status_label(participant_status), 'name':attendee.get_name() } try: message = itip_event['xml'].to_message_itip(from_address, method="REPLY", participant_status=participant_status, message_text=message_text, subject=subject ) except Exception, e: log.error(_("Failed to compose iTip reply message: %r: %s") % (e, traceback.format_exc())) return smtp = smtplib.SMTP("localhost", 10026) # replies go through wallace again if conf.debuglevel > 8: smtp.set_debuglevel(True) try: smtp.sendmail(message['From'], message['To'], message.as_string()) except Exception, e: log.error(_("SMTP sendmail error: %r") % (e)) if smtp: smtp.quit() def send_request(to_address, itip_events, request_text, subject=None, direct=False): """ Send an iTip REQUEST message from the given iCal events """ import smtplib conf = pykolab.getConf() smtp = None if isinstance(itip_events, dict): itip_events = [ itip_events ] for itip_event in itip_events: event_summary = itip_event['xml'].get_summary() message_text = request_text % { 'summary':event_summary } if subject is not None: subject = subject % { 'summary':event_summary } try: message = itip_event['xml'].to_message_itip(None, method="REQUEST", message_text=message_text, subject=subject ) except Exception, e: log.error(_("Failed to compose iTip request message: %r") % (e)) return port = 10027 if direct else 10026 smtp = smtplib.SMTP("localhost", port) if conf.debuglevel > 8: smtp.set_debuglevel(True) try: smtp.sendmail(message['From'], to_address, message.as_string()) except Exception, e: log.error(_("SMTP sendmail error: %r") % (e)) if smtp: smtp.quit() diff --git a/pykolab/logger.py b/pykolab/logger.py index 3ad16e9..481c270 100644 --- a/pykolab/logger.py +++ b/pykolab/logger.py @@ -1,219 +1,243 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import grp import logging import logging.handlers import os import pwd import sys import time from pykolab.translate import _ +class StderrToLogger(object): + """ + Fake file-like stream object that redirects writes to a logger instance. + """ + def __init__(self, logger, log_level=logging.DEBUG): + self.logger = logger + self.log_level = log_level + self.linebuf = '' + + def write(self, buf): + # ugly patch to make smtplib debug logging records appear on one line in log file + # smtplib uses "print>>stderr, var, var" statements for debug logging. These + # statements are splited into separate lines on separating whitespace. + for line in buf.rstrip().splitlines(): + if buf != '\n': + if line.startswith('send:') or line.startswith('reply:'): + self.linebuf = line + return + else: + self.logger.log(self.log_level, '%s %s', self.linebuf, line.rstrip()[:150]) + self.linebuf = '' + + def flush(self): + pass + class Logger(logging.Logger): """ The PyKolab version of a logger. This class wraps the Python native logging library, adding to the loglevel capabilities, a debuglevel capability. """ debuglevel = 0 fork = False loglevel = logging.CRITICAL process_username = 'kolab' process_groupname = 'kolab-n' if hasattr(sys, 'argv'): for arg in sys.argv: if debuglevel == -1: try: debuglevel = int(arg) except ValueError, errmsg: continue loglevel = logging.DEBUG break if '-d' == arg: debuglevel = -1 continue if '-l' == arg: loglevel = -1 continue if '--fork' == arg: fork = True if loglevel == -1: if hasattr(logging,arg.upper()): loglevel = getattr(logging,arg.upper()) else: loglevel = logging.DEBUG if '-u' == arg or '--user' == arg: process_username = -1 continue if arg.startswith('--user='): process_username = arg.split('=')[1] if process_username == -1: process_username = arg if '-g' == arg or '--group' == arg: process_groupname = -1 continue if arg.startswith('--group='): process_groupname = arg.split('=')[1] if process_groupname == -1: process_groupname = arg def __init__(self, *args, **kw): if kw.has_key('name'): name = kw['name'] elif len(args) == 1: name = args[0] else: name = 'pykolab' logging.Logger.__init__(self, name) - plaintextformatter = logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") + plaintextformatter = logging.Formatter("%(asctime)s %(name)s %(levelname)s [%(process)d] %(message)s") if not self.fork: self.console_stdout = logging.StreamHandler(sys.stdout) self.console_stdout.setFormatter(plaintextformatter) self.addHandler(self.console_stdout) if kw.has_key('logfile'): self.logfile = kw['logfile'] else: self.logfile = '/var/log/kolab/pykolab.log' group_gid = 0 user_uid = 0 # Make sure (read: attempt to change) the permissions try: try: (ruid, euid, suid) = os.getresuid() (rgid, egid, sgid) = os.getresgid() except AttributeError, errmsg: ruid = os.getuid() rgid = os.getgid() if ruid == 0: # Means we can setreuid() / setregid() / setgroups() if rgid == 0: # Get group entry details try: ( group_name, group_password, group_gid, group_members ) = grp.getgrnam(self.process_groupname) except KeyError, errmsg: group_name = False if ruid == 0: # Means we haven't switched yet. try: ( user_name, user_password, user_uid, user_gid, user_gecos, user_homedir, user_shell ) = pwd.getpwnam(self.process_username) except KeyError, errmsg: user_name = False if os.path.isfile(self.logfile): try: if not user_uid == 0 or group_gid == 0: os.chown( self.logfile, user_uid, group_gid ) os.chmod(self.logfile, 0660) except Exception, errmsg: self.error(_("Could not change permissions on %s: %r") % (self.logfile, errmsg)) if self.debuglevel > 8: import traceback traceback.print_exc() except Exception, errmsg: if os.path.isfile(self.logfile): self.error(_("Could not change permissions on %s: %r") % (self.logfile, errmsg)) if self.debuglevel > 8: import traceback traceback.print_exc() # Make sure the log file exists try: fhandle = file(self.logfile, 'a') try: os.utime(self.logfile, None) finally: fhandle.close() try: filelog_handler = logging.FileHandler(filename=self.logfile) filelog_handler.setFormatter(plaintextformatter) except IOError, e: print >> sys.stderr, _("Cannot log to file %s: %s") % (self.logfile, e) if not len(self.handlers) > 1: try: self.addHandler(filelog_handler) except: pass except IOError, errmsg: pass def remove_stdout_handler(self): if not self.fork: self.console_stdout.close() self.removeHandler(self.console_stdout) def debug(self, msg, level=1, *args, **kw): self.setLevel(self.loglevel) # Work around other applications not using various levels of debugging if not self.name.startswith('pykolab') and not self.debuglevel == 9: return if level <= self.debuglevel: # TODO: Not the way it's supposed to work! - self.log(logging.DEBUG, '[%d]: %s' % (os.getpid(),msg)) - + self.log(logging.DEBUG, msg) logging.setLoggerClass(Logger) diff --git a/pykolab/plugins/roundcubedb/__init__.py b/pykolab/plugins/roundcubedb/__init__.py index a7f289e..40271c3 100644 --- a/pykolab/plugins/roundcubedb/__init__.py +++ b/pykolab/plugins/roundcubedb/__init__.py @@ -1,69 +1,69 @@ # -*- coding: utf-8 -*- # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli (Kolab Systems) # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; version 3 or, at your option, any later version # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Library General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # import os import pykolab import subprocess from pykolab.translate import _ log = pykolab.getLogger('pykolab.plugins.roundcubedb') conf = pykolab.getConf() class KolabRoundcubedb(object): """ Pykolab plugin to update Roundcube's database on Kolab users db changes """ def __init__(self): pass def add_options(self, *args, **kw): pass def user_delete(self, *args, **kw): """ The arguments passed to the 'user_delete' hook: user - full user entry from LDAP domain - domain name """ - log.debug(_("user_delete: %r") % (kw), level=9) + log.debug(_("user_delete: %r") % (kw), level=8) if os.path.isdir('/usr/share/roundcubemail'): rcpath = '/usr/share/roundcubemail/' elif os.path.isdir('/usr/share/roundcube'): rcpath = '/usr/share/roundcube/' else: log.error(_("Roundcube installation path not found.")) return False result_attribute = conf.get('cyrus-sasl', 'result_attribute') # execute Roundcube's bin/deluser.sh script to do the work if kw.has_key('user') and kw['user'].has_key(result_attribute) and os.path.exists(rcpath + 'bin/deluser.sh'): proc = subprocess.Popen([ 'sudo -u apache', rcpath + 'bin/deluser.sh', kw['user'][result_attribute] ], stderr=subprocess.PIPE, stdout=subprocess.PIPE) procout, procerr = proc.communicate() if proc.returncode != 0: log.error(rcpath + "bin/deluser.sh exited with error %d: %r" % (proc.returncode, procerr)) else: - log.debug(rcpath + "bin/deluser.sh success: %r; %r" % (procout, procerr), level=9) + log.debug(rcpath + "bin/deluser.sh success: %r; %r" % (procout, procerr), level=8) return None diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py index fac1fe7..17dc71c 100644 --- a/wallace/module_invitationpolicy.py +++ b/wallace/module_invitationpolicy.py @@ -1,1481 +1,1451 @@ # -*- coding: utf-8 -*- # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import datetime import os import tempfile import time from urlparse import urlparse import urllib import hashlib import traceback import re from email import message_from_string from email.parser import Parser from email.utils import formataddr from email.utils import getaddresses import modules import pykolab import kolabformat from pykolab import utils from pykolab.auth import Auth from pykolab.conf import Conf from pykolab.imap import IMAP from pykolab.xml import to_dt from pykolab.xml import utils as xmlutils from pykolab.xml import todo_from_message from pykolab.xml import event_from_message from pykolab.xml import participant_status_label from pykolab.itip import objects_from_message from pykolab.itip import check_event_conflict from pykolab.itip import send_reply from pykolab.translate import _ # define some contstants used in the code below ACT_MANUAL = 1 ACT_ACCEPT = 2 ACT_DELEGATE = 4 ACT_REJECT = 8 ACT_UPDATE = 16 ACT_CANCEL_DELETE = 32 ACT_SAVE_TO_FOLDER = 64 COND_IF_AVAILABLE = 128 COND_IF_CONFLICT = 256 COND_TENTATIVE = 512 COND_NOTIFY = 1024 COND_FORWARD = 2048 COND_TYPE_EVENT = 4096 COND_TYPE_TASK = 8192 COND_TYPE_ALL = COND_TYPE_EVENT + COND_TYPE_TASK ACT_TENTATIVE = ACT_ACCEPT + COND_TENTATIVE ACT_UPDATE_AND_NOTIFY = ACT_UPDATE + COND_NOTIFY ACT_SAVE_AND_FORWARD = ACT_SAVE_TO_FOLDER + COND_FORWARD ACT_CANCEL_DELETE_AND_NOTIFY = ACT_CANCEL_DELETE + COND_NOTIFY FOLDER_TYPE_ANNOTATION = '/vendor/kolab/folder-type' MESSAGE_PROCESSED = 1 MESSAGE_FORWARD = 2 policy_name_map = { # policy values applying to all object types 'ALL_MANUAL': ACT_MANUAL + COND_TYPE_ALL, 'ALL_ACCEPT': ACT_ACCEPT + COND_TYPE_ALL, 'ALL_REJECT': ACT_REJECT + COND_TYPE_ALL, 'ALL_DELEGATE': ACT_DELEGATE + COND_TYPE_ALL, # not implemented 'ALL_UPDATE': ACT_UPDATE + COND_TYPE_ALL, 'ALL_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY + COND_TYPE_ALL, 'ALL_SAVE_TO_FOLDER': ACT_SAVE_TO_FOLDER + COND_TYPE_ALL, 'ALL_SAVE_AND_FORWARD': ACT_SAVE_AND_FORWARD + COND_TYPE_ALL, 'ALL_CANCEL_DELETE': ACT_CANCEL_DELETE + COND_TYPE_ALL, 'ALL_CANCEL_DELETE_AND_NOTIFY': ACT_CANCEL_DELETE_AND_NOTIFY + COND_TYPE_ALL, # event related policy values 'EVENT_MANUAL': ACT_MANUAL + COND_TYPE_EVENT, 'EVENT_ACCEPT': ACT_ACCEPT + COND_TYPE_EVENT, 'EVENT_TENTATIVE': ACT_TENTATIVE + COND_TYPE_EVENT, 'EVENT_REJECT': ACT_REJECT + COND_TYPE_EVENT, 'EVENT_DELEGATE': ACT_DELEGATE + COND_TYPE_EVENT, # not implemented 'EVENT_UPDATE': ACT_UPDATE + COND_TYPE_EVENT, 'EVENT_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY + COND_TYPE_EVENT, 'EVENT_ACCEPT_IF_NO_CONFLICT': ACT_ACCEPT + COND_IF_AVAILABLE + COND_TYPE_EVENT, 'EVENT_TENTATIVE_IF_NO_CONFLICT': ACT_ACCEPT + COND_TENTATIVE + COND_IF_AVAILABLE + COND_TYPE_EVENT, 'EVENT_DELEGATE_IF_CONFLICT': ACT_DELEGATE + COND_IF_CONFLICT + COND_TYPE_EVENT, 'EVENT_REJECT_IF_CONFLICT': ACT_REJECT + COND_IF_CONFLICT + COND_TYPE_EVENT, 'EVENT_SAVE_TO_FOLDER': ACT_SAVE_TO_FOLDER + COND_TYPE_EVENT, 'EVENT_SAVE_AND_FORWARD': ACT_SAVE_AND_FORWARD + COND_TYPE_EVENT, 'EVENT_CANCEL_DELETE': ACT_CANCEL_DELETE + COND_TYPE_EVENT, 'EVENT_CANCEL_DELETE_AND_NOTIFY': ACT_CANCEL_DELETE_AND_NOTIFY + COND_TYPE_EVENT, # task related policy values 'TASK_MANUAL': ACT_MANUAL + COND_TYPE_TASK, 'TASK_ACCEPT': ACT_ACCEPT + COND_TYPE_TASK, 'TASK_REJECT': ACT_REJECT + COND_TYPE_TASK, 'TASK_DELEGATE': ACT_DELEGATE + COND_TYPE_TASK, # not implemented 'TASK_UPDATE': ACT_UPDATE + COND_TYPE_TASK, 'TASK_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY + COND_TYPE_TASK, 'TASK_SAVE_TO_FOLDER': ACT_SAVE_TO_FOLDER + COND_TYPE_TASK, 'TASK_SAVE_AND_FORWARD': ACT_SAVE_AND_FORWARD + COND_TYPE_TASK, 'TASK_CANCEL_DELETE': ACT_CANCEL_DELETE + COND_TYPE_TASK, 'TASK_CANCEL_DELETE_AND_NOTIFY': ACT_CANCEL_DELETE_AND_NOTIFY + COND_TYPE_TASK, # legacy values 'ACT_MANUAL': ACT_MANUAL + COND_TYPE_ALL, 'ACT_ACCEPT': ACT_ACCEPT + COND_TYPE_ALL, 'ACT_ACCEPT_IF_NO_CONFLICT': ACT_ACCEPT + COND_IF_AVAILABLE + COND_TYPE_EVENT, 'ACT_TENTATIVE': ACT_TENTATIVE + COND_TYPE_EVENT, 'ACT_TENTATIVE_IF_NO_CONFLICT': ACT_ACCEPT + COND_TENTATIVE + COND_IF_AVAILABLE + COND_TYPE_EVENT, 'ACT_DELEGATE': ACT_DELEGATE + COND_TYPE_ALL, 'ACT_DELEGATE_IF_CONFLICT': ACT_DELEGATE + COND_IF_CONFLICT + COND_TYPE_EVENT, 'ACT_REJECT': ACT_REJECT + COND_TYPE_ALL, 'ACT_REJECT_IF_CONFLICT': ACT_REJECT + COND_IF_CONFLICT + COND_TYPE_EVENT, 'ACT_UPDATE': ACT_UPDATE + COND_TYPE_ALL, 'ACT_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY + COND_TYPE_ALL, 'ACT_CANCEL_DELETE': ACT_CANCEL_DELETE + COND_TYPE_ALL, 'ACT_CANCEL_DELETE_AND_NOTIFY': ACT_CANCEL_DELETE_AND_NOTIFY + COND_TYPE_ALL, 'ACT_SAVE_TO_CALENDAR': ACT_SAVE_TO_FOLDER + COND_TYPE_EVENT, 'ACT_SAVE_AND_FORWARD': ACT_SAVE_AND_FORWARD + COND_TYPE_EVENT, } policy_value_map = dict([(v &~ COND_TYPE_ALL, k) for (k, v) in policy_name_map.iteritems()]) object_type_conditons = { 'event': COND_TYPE_EVENT, 'task': COND_TYPE_TASK } log = pykolab.getLogger('pykolab.wallace') conf = pykolab.getConf() mybasepath = '/var/spool/pykolab/wallace/invitationpolicy/' auth = None imap = None write_locks = [] def __init__(): modules.register('invitationpolicy', execute, description=description()) def accept(filepath): new_filepath = os.path.join( mybasepath, 'ACCEPT', os.path.basename(filepath) ) cleanup() os.rename(filepath, new_filepath) filepath = new_filepath exec('modules.cb_action_ACCEPT(%r, %r)' % ('invitationpolicy',filepath)) def reject(filepath): new_filepath = os.path.join( mybasepath, 'REJECT', os.path.basename(filepath) ) os.rename(filepath, new_filepath) filepath = new_filepath exec('modules.cb_action_REJECT(%r, %r)' % ('invitationpolicy',filepath)) def description(): return """Invitation policy execution module.""" def cleanup(): global auth, imap, write_locks - log.debug("cleanup(): %r, %r" % (auth, imap), level=9) + log.debug("cleanup(): %r, %r" % (auth, imap), level=8) auth.disconnect() del auth # Disconnect IMAP or we lock the mailbox almost constantly imap.disconnect() del imap # remove remaining write locks for key in write_locks: remove_write_lock(key, False) def execute(*args, **kw): global auth, imap # (re)set language to default pykolab.translate.setUserLanguage(conf.get('kolab','default_locale')) if not os.path.isdir(mybasepath): os.makedirs(mybasepath) for stage in ['incoming', 'ACCEPT', 'REJECT', 'HOLD', 'DEFER', 'locks']: if not os.path.isdir(os.path.join(mybasepath, stage)): os.makedirs(os.path.join(mybasepath, stage)) - log.debug(_("Invitation policy called for %r, %r") % (args, kw), level=9) + log.debug(_("Invitation policy called for %r, %r") % (args, kw), level=8) auth = Auth() imap = IMAP() filepath = args[0] # ignore calls on lock files if '/locks/' in filepath or kw.has_key('stage') and kw['stage'] == 'locks': return False log.debug("Invitation policy executing for %r, %r" % (filepath, '/locks/' in filepath), level=8) if kw.has_key('stage'): log.debug(_("Issuing callback after processing to stage %s") % (kw['stage']), level=8) log.debug(_("Testing cb_action_%s()") % (kw['stage']), level=8) if hasattr(modules, 'cb_action_%s' % (kw['stage'])): log.debug(_("Attempting to execute cb_action_%s()") % (kw['stage']), level=8) exec( 'modules.cb_action_%s(%r, %r)' % ( kw['stage'], 'invitationpolicy', filepath ) ) return filepath else: # Move to incoming new_filepath = os.path.join( mybasepath, 'incoming', os.path.basename(filepath) ) if not filepath == new_filepath: log.debug("Renaming %r to %r" % (filepath, new_filepath)) os.rename(filepath, new_filepath) filepath = new_filepath # parse full message message = Parser().parse(open(filepath, 'r')) # invalid message, skip if not message.get('X-Kolab-To'): return filepath recipients = [address for displayname,address in getaddresses(message.get_all('X-Kolab-To'))] sender_email = [address for displayname,address in getaddresses(message.get_all('X-Kolab-From'))][0] any_itips = False recipient_email = None recipient_emails = [] recipient_user_dn = None # An iTip message may contain multiple events. Later on, test if the message # is an iTip message by checking the length of this list. try: itip_events = objects_from_message(message, ['VEVENT','VTODO'], ['REQUEST', 'REPLY', 'CANCEL']) except Exception, errmsg: log.error(_("Failed to parse iTip objects from message: %r" % (errmsg))) itip_events = [] if not len(itip_events) > 0: log.info(_("Message is not an iTip message or does not contain any (valid) iTip objects.")) else: any_itips = True - log.debug(_("iTip objects attached to this message contain the following information: %r") % (itip_events), level=9) + log.debug(_("iTip objects attached to this message contain the following information: %r") % (itip_events), level=8) # See if any iTip actually allocates a user. if any_itips and len([x['uid'] for x in itip_events if x.has_key('attendees') or x.has_key('organizer')]) > 0: auth.connect() # we're looking at the first itip object itip_event = itip_events[0] for recipient in recipients: recipient_user_dn = user_dn_from_email_address(recipient) if recipient_user_dn: receiving_user = auth.get_entry_attributes(None, recipient_user_dn, ['*']) recipient_emails = auth.extract_recipient_addresses(receiving_user) recipient_email = recipient # extend with addresses from delegators # (only do this lookup for REPLY messages) receiving_user['_delegated_mailboxes'] = [] if itip_event['method'] == 'REPLY': for _delegator in auth.list_delegators(recipient_user_dn): if not _delegator['_mailbox_basename'] == None: receiving_user['_delegated_mailboxes'].append( _delegator['_mailbox_basename'].split('@')[0] ) log.debug(_("Recipient emails for %s: %r") % (recipient_user_dn, recipient_emails), level=8) break if not any_itips: log.debug(_("No itips, no users, pass along %r") % (filepath), level=5) return filepath elif recipient_email is None: log.debug(_("iTips, but no users, pass along %r") % (filepath), level=5) return filepath # for replies, the organizer is the recipient if itip_event['method'] == 'REPLY': organizer_mailto = str(itip_event['organizer']).split(':')[-1] user_attendees = [organizer_mailto] if organizer_mailto in recipient_emails else [] else: # Limit the attendees to the one that is actually invited with the current message. attendees = [str(a).split(':')[-1] for a in (itip_event['attendees'] if itip_event.has_key('attendees') else [])] user_attendees = [a for a in attendees if a in recipient_emails] if itip_event.has_key('organizer'): sender_email = itip_event['xml'].get_organizer().email() # abort if no attendee matches the envelope recipient if len(user_attendees) == 0: log.info(_("No user attendee matching envelope recipient %s, skip message") % (recipient_email)) return filepath log.debug(_("Receiving user: %r") % (receiving_user), level=8) # set recipient_email to the matching attendee mailto: address recipient_email = user_attendees[0] # change gettext language to the preferredlanguage setting of the receiving user if receiving_user.has_key('preferredlanguage'): pykolab.translate.setUserLanguage(receiving_user['preferredlanguage']) # find user's kolabInvitationPolicy settings and the matching policy values type_condition = object_type_conditons.get(itip_event['type'], COND_TYPE_ALL) policies = get_matching_invitation_policies(receiving_user, sender_email, type_condition) # select a processing function according to the iTip request method method_processing_map = { 'REQUEST': process_itip_request, 'REPLY': process_itip_reply, 'CANCEL': process_itip_cancel } done = None if method_processing_map.has_key(itip_event['method']): processor_func = method_processing_map[itip_event['method']] # connect as cyrus-admin imap.connect() for policy in policies: log.debug(_("Apply invitation policy %r for sender %r") % (policy_value_map[policy], sender_email), level=8) done = processor_func(itip_event, policy, recipient_email, sender_email, receiving_user) # matching policy found if done is not None: break # remove possible write lock from this iteration remove_write_lock(get_lock_key(receiving_user, itip_event['uid'])) else: log.debug(_("Ignoring '%s' iTip method") % (itip_event['method']), level=8) # message has been processed by the module, remove it if done == MESSAGE_PROCESSED: log.debug(_("iTip message %r consumed by the invitationpolicy module") % (message.get('Message-ID')), level=5) os.unlink(filepath) cleanup() return None # accept message into the destination inbox accept(filepath) def process_itip_request(itip_event, policy, recipient_email, sender_email, receiving_user): """ Process an iTip REQUEST message according to the given policy """ # if invitation policy is set to MANUAL, pass message along if policy & ACT_MANUAL: log.info(_("Pass invitation for manual processing")) return MESSAGE_FORWARD try: receiving_attendee = itip_event['xml'].get_attendee_by_email(recipient_email) - log.debug(_("Receiving Attendee: %r") % (receiving_attendee), level=9) + log.debug(_("Receiving Attendee: %r") % (receiving_attendee), level=8) except Exception, errmsg: log.error("Could not find envelope attendee: %r" % (errmsg)) return MESSAGE_FORWARD # process request to participating attendees with RSVP=TRUE or PARTSTAT=NEEDS-ACTION is_task = itip_event['type'] == 'task' nonpart = receiving_attendee.get_role() == kolabformat.NonParticipant partstat = receiving_attendee.get_participant_status() save_object = not nonpart or not partstat == kolabformat.PartNeedsAction rsvp = receiving_attendee.get_rsvp() scheduling_required = rsvp or partstat == kolabformat.PartNeedsAction respond_with = receiving_attendee.get_participant_status(True) condition_fulfilled = True # find existing event in user's calendar (existing, master) = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True) # compare sequence number to determine a (re-)scheduling request if existing is not None: scheduling_required = itip_event['sequence'] > 0 and itip_event['sequence'] > existing.get_sequence() log.debug(_("Scheduling required: %r, for existing %s: %s") % (scheduling_required, existing.type, existing.get_uid()), level=8) save_object = True # if scheduling: check availability (skip that for tasks) if scheduling_required: if not is_task and policy & (COND_IF_AVAILABLE | COND_IF_CONFLICT): condition_fulfilled = check_availability(itip_event, receiving_user) if not is_task and policy & COND_IF_CONFLICT: condition_fulfilled = not condition_fulfilled log.debug(_("Precondition for object %r fulfilled: %r") % (itip_event['uid'], condition_fulfilled), level=5) if existing: respond_with = None if policy & ACT_ACCEPT and condition_fulfilled: respond_with = 'TENTATIVE' if policy & COND_TENTATIVE else 'ACCEPTED' elif policy & ACT_REJECT and condition_fulfilled: respond_with = 'DECLINED' # TODO: only save declined invitation when a certain config option is set? elif policy & ACT_DELEGATE and condition_fulfilled: # TODO: delegate (but to whom?) return None # auto-update changes if enabled for this user elif policy & ACT_UPDATE and existing: # compare sequence number to avoid outdated updates if not itip_event['sequence'] == existing.get_sequence(): log.info(_("The iTip request sequence (%r) doesn't match the referred object version (%r). Ignoring.") % ( itip_event['sequence'], existing.get_sequence() )) return None log.debug(_("Auto-updating %s %r on iTip REQUEST (no re-scheduling)") % (existing.type, existing.uid), level=8) save_object = True rsvp = False # retain task status and percent-complete properties from my old copy if is_task: itip_event['xml'].set_status(existing.get_status()) itip_event['xml'].set_percentcomplete(existing.get_percentcomplete()) if policy & COND_NOTIFY: sender = itip_event['xml'].get_organizer() comment = itip_event['xml'].get_comment() send_update_notification(itip_event['xml'], receiving_user, existing, False, sender, comment) # if RSVP, send an iTip REPLY if rsvp or scheduling_required: # set attendee's CN from LDAP record if yet missing if not receiving_attendee.get_name() and receiving_user.has_key('cn'): receiving_attendee.set_name(receiving_user['cn']) # send iTip reply if respond_with is not None and not respond_with == 'NEEDS-ACTION': receiving_attendee.set_participant_status(respond_with) send_reply(recipient_email, itip_event, invitation_response_text(itip_event['type']), subject=_('"%(summary)s" has been %(status)s')) elif policy & ACT_SAVE_TO_FOLDER: # copy the invitation into the user's default folder with PARTSTAT=NEEDS-ACTION itip_event['xml'].set_attendee_participant_status(receiving_attendee, respond_with or 'NEEDS-ACTION') save_object = True else: # policy doesn't match, pass on to next one return None if save_object: targetfolder = None # delete old version from IMAP if existing: targetfolder = existing._imap_folder delete_object(existing) elif master and hasattr(master, '_imap_folder'): targetfolder = master._imap_folder delete_object(master) if not nonpart or existing: # save new copy from iTip if store_object(itip_event['xml'], receiving_user, targetfolder, master): if policy & COND_FORWARD: log.debug(_("Forward invitation for notification"), level=5) return MESSAGE_FORWARD else: return MESSAGE_PROCESSED return None def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiving_user): """ Process an iTip REPLY message according to the given policy """ # if invitation policy is set to MANUAL, pass message along if policy & ACT_MANUAL: log.info(_("Pass reply for manual processing")) return MESSAGE_FORWARD # auto-update is enabled for this user if policy & ACT_UPDATE: try: sender_attendee = itip_event['xml'].get_attendee_by_email(sender_email) - log.debug(_("Sender Attendee: %r") % (sender_attendee), level=9) + log.debug(_("Sender Attendee: %r") % (sender_attendee), level=8) except Exception, errmsg: log.error("Could not find envelope sender attendee: %r" % (errmsg)) return MESSAGE_FORWARD # find existing event in user's calendar # sets/checks lock to avoid concurrent wallace processes trying to update the same event simultaneously (existing, master) = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True) if existing: # compare sequence number to avoid outdated replies? if not itip_event['sequence'] == existing.get_sequence(): log.info(_("The iTip reply sequence (%r) doesn't match the referred object version (%r). Forwarding to Inbox.") % ( itip_event['sequence'], existing.get_sequence() )) remove_write_lock(existing._lock_key) return MESSAGE_FORWARD log.debug(_("Auto-updating %s %r on iTip REPLY") % (existing.type, existing.uid), level=8) updated_attendees = [] try: existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status(), rsvp=False) existing_attendee = existing.get_attendee(sender_email) updated_attendees.append(existing_attendee) except Exception, errmsg: log.error("Could not find corresponding attende in organizer's copy: %r" % (errmsg)) # append delegated-from attendee ? if len(sender_attendee.get_delegated_from()) > 0: existing.add_attendee(sender_attendee) updated_attendees.append(sender_attendee) else: # TODO: accept new participant if ACT_ACCEPT ? remove_write_lock(existing._lock_key) return MESSAGE_FORWARD # append delegated-to attendee if len(sender_attendee.get_delegated_to()) > 0: try: delegatee_email = sender_attendee.get_delegated_to(True)[0] sender_delegatee = itip_event['xml'].get_attendee_by_email(delegatee_email) existing_delegatee = existing.find_attendee(delegatee_email) if not existing_delegatee: existing.add_attendee(sender_delegatee) - log.debug(_("Add delegatee: %r") % (sender_delegatee.to_dict()), level=9) + log.debug(_("Add delegatee: %r") % (sender_delegatee.to_dict()), level=8) else: existing_delegatee.copy_from(sender_delegatee) - log.debug(_("Update existing delegatee: %r") % (existing_delegatee.to_dict()), level=9) + log.debug(_("Update existing delegatee: %r") % (existing_delegatee.to_dict()), level=8) updated_attendees.append(sender_delegatee) # copy all parameters from replying attendee (e.g. delegated-to, role, etc.) existing_attendee.copy_from(sender_attendee) existing.update_attendees([existing_attendee]) - log.debug(_("Update delegator: %r") % (existing_attendee.to_dict()), level=9) + log.debug(_("Update delegator: %r") % (existing_attendee.to_dict()), level=8) except Exception, errmsg: log.error("Could not find delegated-to attendee: %r" % (errmsg)) # update the organizer's copy of the object if update_object(existing, receiving_user, master): if policy & COND_NOTIFY: send_update_notification(existing, receiving_user, existing, True, sender_attendee, itip_event['xml'].get_comment()) # update all other attendee's copies if conf.get('wallace','invitationpolicy_autoupdate_other_attendees_on_reply'): propagate_changes_to_attendees_accounts(existing, updated_attendees) return MESSAGE_PROCESSED else: log.error(_("The object referred by this reply was not found in the user's folders. Forwarding to Inbox.")) return MESSAGE_FORWARD return None def process_itip_cancel(itip_event, policy, recipient_email, sender_email, receiving_user): """ Process an iTip CANCEL message according to the given policy """ # if invitation policy is set to MANUAL, pass message along if policy & ACT_MANUAL: log.info(_("Pass cancellation for manual processing")) return MESSAGE_FORWARD # auto-update the local copy if policy & ACT_UPDATE or policy & ACT_CANCEL_DELETE: # find existing object in user's folders (existing, master) = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True) remove_object = policy & ACT_CANCEL_DELETE if existing: # on this-and-future cancel requests, set the recurrence until date on the master event if itip_event['recurrence-id'] and master and itip_event['xml'].get_thisandfuture(): rrule = master.get_recurrence() rrule.set_count(0) rrule.set_until(existing.get_start() + datetime.timedelta(days=-1)) master.set_recurrence(rrule) existing.set_recurrence_id(existing.get_recurrence_id(), True) remove_object = False # delete the local copy if remove_object: # remove exception and register an exdate to the main event if master: log.debug(_("Remove cancelled %s instance %s from %r") % (existing.type, itip_event['recurrence-id'], existing.uid), level=8) master.add_exception_date(existing.get_start()) master.del_exception(existing) success = update_object(master, receiving_user) # delete main event else: success = delete_object(existing) # update the local copy with STATUS=CANCELLED else: log.debug(_("Update cancelled %s %r with STATUS=CANCELLED") % (existing.type, existing.uid), level=8) existing.set_status('CANCELLED') existing.set_transparency(True) success = update_object(existing, receiving_user, master) if success: # send cancellation notification if policy & COND_NOTIFY: sender = itip_event['xml'].get_organizer() comment = itip_event['xml'].get_comment() send_cancel_notification(existing, receiving_user, remove_object, sender, comment) return MESSAGE_PROCESSED else: log.error(_("The object referred by this cancel request was not found in the user's folders. Forwarding to Inbox.")) return MESSAGE_FORWARD return None def user_dn_from_email_address(email_address): """ Resolves the given email address to a Kolab user entity """ global auth if not auth: auth = Auth() auth.connect() # return cached value if user_dn_from_email_address.cache.has_key(email_address): return user_dn_from_email_address.cache[email_address] local_domains = auth.list_domains() if local_domains is not None: local_domains = list(set(local_domains.keys())) if not email_address.split('@')[1] in local_domains: user_dn_from_email_address.cache[email_address] = None return None log.debug(_("Checking if email address %r belongs to a local user") % (email_address), level=8) user_dn = auth.find_user_dn(email_address, True) if isinstance(user_dn, basestring): log.debug(_("User DN: %r") % (user_dn), level=8) else: - log.debug(_("No user record(s) found for %r") % (email_address), level=9) + log.debug(_("No user record(s) found for %r") % (email_address), level=8) # remember this lookup user_dn_from_email_address.cache[email_address] = user_dn return user_dn user_dn_from_email_address.cache = {} def get_matching_invitation_policies(receiving_user, sender_email, type_condition=COND_TYPE_ALL): # get user's kolabInvitationPolicy settings policies = receiving_user['kolabinvitationpolicy'] if receiving_user.has_key('kolabinvitationpolicy') else [] if policies and not isinstance(policies, list): policies = [policies] if len(policies) == 0: policies = conf.get_list('wallace', 'kolab_invitation_policy') # match policies agains the given sender_email matches = [] for p in policies: if ':' in p: (value, domain) = p.split(':', 1) else: value = p domain = '' if domain == '' or domain == '*' or str(sender_email).endswith(domain): value = value.upper() if policy_name_map.has_key(value): val = policy_name_map[value] # append if type condition matches if val & type_condition: matches.append(val &~ COND_TYPE_ALL) # add manual as default action if len(matches) == 0: matches.append(ACT_MANUAL) return matches def imap_proxy_auth(user_rec): """ Perform IMAP login using proxy authentication with admin credentials """ global imap mail_attribute = conf.get('cyrus-sasl', 'result_attribute') if mail_attribute is None: mail_attribute = 'mail' mail_attribute = mail_attribute.lower() if not user_rec.has_key(mail_attribute): log.error(_("User record doesn't have the mailbox attribute %r set" % (mail_attribute))) return False # do IMAP prox auth with the given user backend = conf.get('kolab', 'imap_backend') admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') try: imap.disconnect() imap.connect(login=False) imap.login_plain(admin_login, admin_password, user_rec[mail_attribute]) except Exception, errmsg: log.error(_("IMAP proxy authentication failed: %r") % (errmsg)) return False return True def list_user_folders(user_rec, _type): """ Get a list of the given user's private calendar/tasks folders """ global imap # return cached list if '_imap_folders' in user_rec: return user_rec['_imap_folders'] result = [] if not imap_proxy_auth(user_rec): return result folders = imap.get_metadata('*') log.debug( _("List %r folders for user %r: %r") % ( _type, user_rec['mail'], folders ), level=8 ) (ns_personal, ns_other, ns_shared) = imap.namespaces() _folders = {} # Filter the folders by type relevance for folder, metadata in folders.items(): key = '/shared' + FOLDER_TYPE_ANNOTATION if key in metadata: if metadata[key].startswith(_type): _folders[folder] = metadata key = '/private' + FOLDER_TYPE_ANNOTATION if key in metadata: if metadata[key].startswith(_type): _folders[folder] = metadata for folder, metadata in _folders.items(): folder_delegated = False # Exclude shared and other user's namespace # # First, test if this is another users folder if ns_other is not None and folder.startswith(ns_other): # If we have no delegated mailboxes, we can skip this entirely if '_delegated_mailboxes' not in user_rec: continue for _m in user_rec['_delegated_mailboxes']: if folder.startswith(ns_other + _m + '/'): folder_delegated = True if not folder_delegated: continue # TODO: list shared folders the user has write privileges ? if ns_shared is not None: if len([_ns for _ns in ns_shared if folder.startswith(_ns)]) > 0: continue key = '/shared' + FOLDER_TYPE_ANNOTATION if key in metadata: if metadata[key].startswith(_type): result.append(folder) key = '/private' + FOLDER_TYPE_ANNOTATION if key in metadata: if metadata[key].startswith(_type): result.append(folder) # store default folder in user record if metadata[key].endswith('.default'): user_rec['_default_folder'] = folder continue # store private and confidential folders in user record if metadata[key].endswith('.confidential'): if '_confidential_folder' not in user_rec: user_rec['_confidential_folder'] = folder continue if metadata[key].endswith('.private'): if '_private_folder' not in user_rec: user_rec['_private_folder'] = folder continue # cache with user record user_rec['_imap_folders'] = result return result def find_existing_object(uid, type, recurrence_id, user_rec, lock=False): """ Search user's private folders for the given object (by UID+type) """ global imap lock_key = None if lock: lock_key = get_lock_key(user_rec, uid) set_write_lock(lock_key) event = None master = None for folder in list_user_folders(user_rec, type): log.debug(_("Searching folder %r for %s %r") % (folder, type, uid), level=8) imap.imap.m.select(imap.folder_utf7(folder)) res, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid)) for num in reversed(data[0].split()): res, data = imap.imap.m.fetch(num, '(UID RFC822)') try: msguid = re.search(r"\WUID (\d+)", data[0][0]).group(1) except Exception, errmsg: log.error(_("No UID found in IMAP response: %r") % (data[0][0])) continue try: if type == 'task': event = todo_from_message(message_from_string(data[0][1])) else: event = event_from_message(message_from_string(data[0][1])) # find instance in a recurring series if recurrence_id and (event.is_recurring() or event.has_exceptions() or event.get_recurrence_id()): master = event event = master.get_instance(recurrence_id) setattr(master, '_imap_folder', folder) setattr(master, '_msguid', msguid) # return master, even if instance is not found if not event and master.uid == uid: return (event, master) if event is not None: setattr(event, '_imap_folder', folder) setattr(event, '_lock_key', lock_key) setattr(event, '_msguid', msguid) except Exception, errmsg: log.error(_("Failed to parse %s from message %s/%s: %s") % (type, folder, num, traceback.format_exc())) event = None master = None continue if event and event.uid == uid: return (event, master) if lock_key is not None: remove_write_lock(lock_key) return (event, master) def check_availability(itip_event, receiving_user): """ For the receiving user, determine if the event in question is in conflict. """ start = time.time() num_messages = 0 conflict = False # return previously detected conflict if itip_event.has_key('_conflicts'): return not itip_event['_conflicts'] for folder in list_user_folders(receiving_user, 'event'): log.debug(_("Listing events from folder %r") % (folder), level=8) imap.imap.m.select(imap.folder_utf7(folder)) res, data = imap.imap.m.search(None, '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")') num_messages += len(data[0].split()) for num in reversed(data[0].split()): event = None res, data = imap.imap.m.fetch(num, '(RFC822)') try: event = event_from_message(message_from_string(data[0][1])) except Exception, errmsg: log.error(_("Failed to parse event from message %s/%s: %r") % (folder, num, errmsg)) continue if event and event.uid: conflict = check_event_conflict(event, itip_event) if conflict: log.info(_("Existing event %r conflicts with invitation %r") % (event.uid, itip_event['uid'])) break if conflict: break end = time.time() - log.debug(_("start: %r, end: %r, total: %r, messages: %d") % (start, end, (end-start), num_messages), level=9) + log.debug(_("start: %r, end: %r, total: %r, messages: %d") % (start, end, (end-start), num_messages), level=8) # remember the result of this check for further iterations itip_event['_conflicts'] = conflict return not conflict def set_write_lock(key, wait=True): """ Set a write-lock for the given key and wait if such a lock already exists """ if not os.path.isdir(mybasepath): os.makedirs(mybasepath) if not os.path.isdir(os.path.join(mybasepath, 'locks')): os.makedirs(os.path.join(mybasepath, 'locks')) filename = os.path.join(mybasepath, 'locks', key + '.lock') locktime = 0 if os.path.isfile(filename): locktime = os.path.getmtime(filename) # wait if file lock is in place while time.time() < locktime + 300: if not wait: return False - log.debug(_("%r is locked, waiting...") % (key), level=9) + log.debug(_("%r is locked, waiting...") % (key), level=8) time.sleep(0.5) locktime = os.path.getmtime(filename) if os.path.isfile(filename) else 0 # touch the file if os.path.isfile(filename): os.utime(filename, None) else: open(filename, 'w').close() # register active lock write_locks.append(key) return True def remove_write_lock(key, update=True): """ Remove the lock file for the given key """ global write_locks if key is not None: file = os.path.join(mybasepath, 'locks', key + '.lock') if os.path.isfile(file): os.remove(file) if update: write_locks = [k for k in write_locks if not k == key] def get_lock_key(user, uid): return hashlib.md5("%s/%s" % (user['mail'], uid)).hexdigest() def update_object(object, user_rec, master=None): """ Update the given object in IMAP (i.e. delete + append) """ success = False saveobj = object # updating a single instance only: use master event if object.get_recurrence_id() and master: saveobj = master if hasattr(saveobj, '_imap_folder'): if delete_object(saveobj): saveobj.set_lastmodified() # update last-modified timestamp success = store_object(object, user_rec, saveobj._imap_folder, master) # remove write lock for this event if hasattr(saveobj, '_lock_key') and saveobj._lock_key is not None: remove_write_lock(saveobj._lock_key) return success def store_object(object, user_rec, targetfolder=None, master=None): """ Append the given object to the user's default calendar/tasklist """ # find calendar folder to save object to if not specified if targetfolder is None: targetfolders = list_user_folders(user_rec, object.type) oc = object.get_classification() # use *.confidential/private folder for confidential/private invitations if oc == kolabformat.ClassConfidential and user_rec.has_key('_confidential_folder'): targetfolder = user_rec['_confidential_folder'] elif oc == kolabformat.ClassPrivate and user_rec.has_key('_private_folder'): targetfolder = user_rec['_private_folder'] # use *.default folder if exists elif user_rec.has_key('_default_folder'): targetfolder = user_rec['_default_folder'] # fallback to any existing folder of specified type elif targetfolders is not None and len(targetfolders) > 0: targetfolder = targetfolders[0] if targetfolder is None: log.error(_("Failed to save %s: no target folder found for user %r") % (object.type, user_rec['mail'])) return False saveobj = object # updating a single instance only: add exception to master event if object.get_recurrence_id() and master: object.set_lastmodified() # update last-modified timestamp master.add_exception(object) saveobj = master log.debug(_("Save %s %r to user folder %r") % (saveobj.type, saveobj.uid, targetfolder), level=8) try: imap.imap.m.select(imap.folder_utf7(targetfolder)) result = imap.imap.m.append( imap.folder_utf7(targetfolder), None, None, saveobj.to_message(creator="Kolab Server ").as_string() ) return result except Exception, errmsg: log.error(_("Failed to save %s to user folder at %r: %r") % ( saveobj.type, targetfolder, errmsg )) return False def delete_object(existing): """ Removes the IMAP object with the given UID from a user's folder """ targetfolder = existing._imap_folder msguid = existing._msguid if hasattr(existing, '_msguid') else None try: imap.imap.m.select(imap.folder_utf7(targetfolder)) # delete by IMAP UID if msguid is not None: log.debug(_("Delete %s %r in %r by UID: %r") % ( existing.type, existing.uid, targetfolder, msguid ), level=8) imap.imap.m.uid('store', msguid, '+FLAGS', '(\\Deleted)') else: res, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % existing.uid) log.debug(_("Delete %s %r in %r: %r") % ( existing.type, existing.uid, targetfolder, data ), level=8) for num in data[0].split(): imap.imap.m.store(num, '+FLAGS', '(\\Deleted)') imap.imap.m.expunge() return True except Exception, errmsg: log.error(_("Failed to delete %s from folder %r: %r") % ( existing.type, targetfolder, errmsg )) return False def send_update_notification(object, receiving_user, old=None, reply=True, sender=None, comment=None): """ Send a (consolidated) notification about the current participant status to organizer """ global auth import smtplib from email.MIMEText import MIMEText from email.Utils import formatdate from email.header import Header from email import charset # encode unicode strings with quoted-printable charset.add_charset('utf-8', charset.SHORTEST, charset.QP) organizer = object.get_organizer() orgemail = organizer.email() orgname = organizer.name() itip_comment = None if sender is not None and not comment == '': itip_comment = _("%s commented: %s") % (_attendee_name(sender), comment) if reply: log.debug(_("Compose participation status summary for %s %r to user %r") % ( object.type, object.uid, receiving_user['mail'] ), level=8) auto_replies_expected = 0 auto_replies_received = 0 is_manual_reply = True partstats = {'ACCEPTED': [], 'TENTATIVE': [], 'DECLINED': [], 'DELEGATED': [], 'IN-PROCESS': [], 'COMPLETED': [], 'PENDING': []} for attendee in object.get_attendees(): parstat = attendee.get_participant_status(True) if partstats.has_key(parstat): partstats[parstat].append(attendee.get_displayname()) else: partstats['PENDING'].append(attendee.get_displayname()) # look-up kolabinvitationpolicy for this attendee if attendee.get_cutype() == kolabformat.CutypeResource: resource_dns = auth.find_resource(attendee.get_email()) if isinstance(resource_dns, list): attendee_dn = resource_dns[0] if len(resource_dns) > 0 else None else: attendee_dn = resource_dns else: attendee_dn = user_dn_from_email_address(attendee.get_email()) if attendee_dn: attendee_rec = auth.get_entry_attributes(None, attendee_dn, ['kolabinvitationpolicy']) if is_auto_reply(attendee_rec, orgemail, object.type): auto_replies_expected += 1 if not parstat == 'NEEDS-ACTION': auto_replies_received += 1 if sender is not None and sender.get_email() == attendee.get_email(): is_manual_reply = False # skip notification until we got replies from all automatically responding attendees if not is_manual_reply and auto_replies_received < auto_replies_expected: log.debug(_("Waiting for more automated replies (got %d of %d); skipping notification") % ( auto_replies_received, auto_replies_expected ), level=8) return # build notification message body roundup = '' if itip_comment is not None: roundup += "\n" + itip_comment for status,attendees in partstats.iteritems(): if len(attendees) > 0: roundup += "\n" + participant_status_label(status) + ":\n\t" + "\n\t".join(attendees) + "\n" else: # build notification message body roundup = '' if itip_comment is not None: roundup += "\n" + itip_comment roundup += "\n" + _("Changes submitted by %s have been automatically applied.") % (orgname if orgname else orgemail) # list properties changed from previous version if old: diff = xmlutils.compute_diff(old.to_dict(), object.to_dict()) if len(diff) > 1: roundup += "\n" for change in diff: if not change['property'] in ['created','lastmodified-date','sequence']: new_value = xmlutils.property_to_string(change['property'], change['new']) if change['new'] else _("(removed)") if new_value: roundup += "\n- %s: %s" % (xmlutils.property_label(change['property']), new_value) # compose different notification texts for events/tasks if object.type == 'task': message_text = _(""" The assignment for '%(summary)s' has been updated in your tasklist. %(roundup)s """) % { 'summary': object.get_summary(), 'roundup': roundup } else: message_text = _(""" The event '%(summary)s' at %(start)s has been updated in your calendar. %(roundup)s """) % { 'summary': object.get_summary(), 'start': xmlutils.property_to_string('start', object.get_start()), 'roundup': roundup } if object.get_recurrence_id(): message_text += _("NOTE: This update only refers to this single occurrence!") + "\n" message_text += "\n" + _("*** This is an automated message. Please do not reply. ***") # compose mime message msg = MIMEText(utils.stripped_message(message_text), _charset='utf-8') msg['To'] = receiving_user['mail'] msg['Date'] = formatdate(localtime=True) msg['Subject'] = utils.str2unicode(_('"%s" has been updated') % (object.get_summary())) msg['From'] = Header(utils.str2unicode('%s' % orgname) if orgname else '') msg['From'].append("<%s>" % orgemail) - smtp = smtplib.SMTP("localhost", 10027) - - if conf.debuglevel > 8: - smtp.set_debuglevel(True) - - success = False - retries = 5 - - while not success and retries > 0: - try: - success = smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string()) - log.debug(_("Sent update notification to %r: %r") % (receiving_user['mail'], success), level=8) - smtp.quit() - break - except Exception, errmsg: - log.error(_("SMTP sendmail error: %r") % (errmsg)) - - time.sleep(10) - retries -= 1 - - return success - + modules._sendmail(orgemail, receiving_user['mail'], msg.as_string()) + log.debug(_("Sent update notification to %r: %r") % (receiving_user['mail'], success), level=8) def send_cancel_notification(object, receiving_user, deleted=False, sender=None, comment=None): """ Send a notification about event/task cancellation """ import smtplib from email.MIMEText import MIMEText from email.Utils import formatdate from email.header import Header from email import charset # encode unicode strings with quoted-printable charset.add_charset('utf-8', charset.SHORTEST, charset.QP) log.debug(_("Send cancellation notification for %s %r to user %r") % ( object.type, object.uid, receiving_user['mail'] ), level=8) organizer = object.get_organizer() orgemail = organizer.email() orgname = organizer.name() # compose different notification texts for events/tasks if object.type == 'task': message_text = _("The assignment for '%(summary)s' has been cancelled by %(organizer)s.") % { 'summary': object.get_summary(), 'organizer': orgname if orgname else orgemail } if deleted: message_text += " " + _("The copy in your tasklist has been removed accordingly.") else: message_text += " " + _("The copy in your tasklist has been marked as cancelled accordingly.") else: message_text = _("The event '%(summary)s' at %(start)s has been cancelled by %(organizer)s.") % { 'summary': object.get_summary(), 'start': xmlutils.property_to_string('start', object.get_start()), 'organizer': orgname if orgname else orgemail } if deleted: message_text += " " + _("The copy in your calendar has been removed accordingly.") else: message_text += " " + _("The copy in your calendar has been marked as cancelled accordingly.") if sender is not None and not comment == '': message_text += "\n" + _("%s commented: %s") % (_attendee_name(sender), comment) if object.get_recurrence_id(): message_text += "\n" + _("NOTE: This cancellation only refers to this single occurrence!") message_text += "\n\n" + _("*** This is an automated message. Please do not reply. ***") # compose mime message msg = MIMEText(utils.stripped_message(message_text), _charset='utf-8') msg['To'] = receiving_user['mail'] msg['Date'] = formatdate(localtime=True) msg['Subject'] = utils.str2unicode(_('"%s" has been cancelled') % (object.get_summary())) msg['From'] = Header(utils.str2unicode('%s' % orgname) if orgname else '') msg['From'].append("<%s>" % orgemail) - smtp = smtplib.SMTP("localhost", 10027) - - if conf.debuglevel > 8: - smtp.set_debuglevel(True) - - try: - smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string()) - except Exception, errmsg: - log.error(_("SMTP sendmail error: %r") % (errmsg)) - - smtp.quit() - + modules._sendmail(orgemail, receiving_user['mail'], msg.as_string()) + log.debug(_("Sent cancel notification to %r: %r") % (receiving_user['mail'], success), level=8) def is_auto_reply(user, sender_email, type): accept_available = False accept_conflicts = False for policy in get_matching_invitation_policies(user, sender_email, object_type_conditons.get(type, COND_TYPE_EVENT)): if policy & (ACT_ACCEPT | ACT_REJECT | ACT_DELEGATE): if check_policy_condition(policy, True): accept_available = True if check_policy_condition(policy, False): accept_conflicts = True # we have both cases covered by a policy if accept_available and accept_conflicts: return True # manual action reached if policy & (ACT_MANUAL | ACT_SAVE_TO_FOLDER): return False return False def check_policy_condition(policy, available): condition_fulfilled = True if policy & (COND_IF_AVAILABLE | COND_IF_CONFLICT): condition_fulfilled = available if policy & COND_IF_CONFLICT: condition_fulfilled = not condition_fulfilled return condition_fulfilled def propagate_changes_to_attendees_accounts(object, updated_attendees=None): """ Find and update copies of this object in all attendee's personal folders """ recurrence_id = object.get_recurrence_id() for attendee in object.get_attendees(): attendee_user_dn = user_dn_from_email_address(attendee.get_email()) if attendee_user_dn: attendee_user = auth.get_entry_attributes(None, attendee_user_dn, ['*']) (attendee_object, master_object) = find_existing_object(object.uid, object.type, recurrence_id, attendee_user, True) # does IMAP authenticate if attendee_object: # find attendee's entry by one of its email addresses attendee_emails = auth.extract_recipient_addresses(attendee_user) for attendee_email in attendee_emails: try: attendee_entry = attendee_object.get_attendee_by_email(attendee_email) except: attendee_entry = None if attendee_entry: break # copy all attendees from master object (covers additions and removals) new_attendees = [] for a in object.get_attendees(): # keep my own entry intact if attendee_entry is not None and attendee_entry.get_email() == a.get_email(): new_attendees.append(attendee_entry) else: new_attendees.append(a) attendee_object.set_attendees(new_attendees) if updated_attendees and not recurrence_id: log.debug("Update Attendees %r for %s" % ([a.get_email()+':'+a.get_participant_status(True) for a in updated_attendees], attendee_user['mail']), level=8) attendee_object.update_attendees(updated_attendees, False) success = update_object(attendee_object, attendee_user, master_object) log.debug(_("Updated %s's copy of %r: %r") % (attendee_user['mail'], object.uid, success), level=8) else: log.debug(_("Attendee %s's copy of %r not found") % (attendee_user['mail'], object.uid), level=8) else: log.debug(_("Attendee %r not found in LDAP") % (attendee.get_email()), level=8) def invitation_response_text(type): footer = "\n\n" + _("*** This is an automated message. Please do not reply. ***") if type == 'task': return _("%(name)s has %(status)s your assignment for %(summary)s.") + footer else: return _("%(name)s has %(status)s your invitation for %(summary)s.") + footer def _attendee_name(attendee): # attendee here can be Attendee or ContactReference try: name = attendee.get_name() except Exception: name = attendee.name() if name == '': try: name = attendee.get_email() except Exception: name = attendee.email() return name diff --git a/wallace/module_resources.py b/wallace/module_resources.py index ed2baf8..c216276 100644 --- a/wallace/module_resources.py +++ b/wallace/module_resources.py @@ -1,1456 +1,1456 @@ # -*- coding: utf-8 -*- # Copyright 2010-2015 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import datetime import icalendar import os import pytz import random import tempfile import time from urlparse import urlparse from dateutil.tz import tzlocal import base64 import uuid import re from email import message_from_string from email.parser import Parser from email.utils import formataddr from email.utils import getaddresses import modules import pykolab import kolabformat from pykolab.auth import Auth from pykolab.conf import Conf from pykolab.imap import IMAP from pykolab.xml import to_dt from pykolab.xml import utils as xmlutils from pykolab.xml import event_from_message from pykolab.xml import participant_status_label from pykolab.itip import events_from_message from pykolab.itip import check_event_conflict from pykolab.translate import _ # define some contstants used in the code below COND_NOTIFY = 256 ACT_MANUAL = 1 ACT_ACCEPT = 2 ACT_REJECT = 8 ACT_ACCEPT_AND_NOTIFY = ACT_ACCEPT + COND_NOTIFY policy_name_map = { 'ACT_MANUAL': ACT_MANUAL, 'ACT_ACCEPT': ACT_ACCEPT, 'ACT_REJECT': ACT_REJECT, 'ACT_ACCEPT_AND_NOTIFY': ACT_ACCEPT_AND_NOTIFY } log = pykolab.getLogger('pykolab.wallace') conf = pykolab.getConf() mybasepath = '/var/spool/pykolab/wallace/resources/' auth = None imap = None def __init__(): modules.register('resources', execute, description=description(), heartbeat=heartbeat) def accept(filepath): new_filepath = os.path.join( mybasepath, 'ACCEPT', os.path.basename(filepath) ) cleanup() os.rename(filepath, new_filepath) filepath = new_filepath exec('modules.cb_action_ACCEPT(%r, %r)' % ('resources',filepath)) def description(): return """Resource management module.""" def cleanup(): global auth, imap - log.debug("cleanup(): %r, %r" % (auth, imap), level=9) + log.debug("cleanup(): %r, %r" % (auth, imap), level=8) auth.disconnect() del auth # Disconnect IMAP or we lock the mailbox almost constantly imap.disconnect() del imap def execute(*args, **kw): global auth, imap # (re)set language to default pykolab.translate.setUserLanguage(conf.get('kolab','default_locale')) if not os.path.isdir(mybasepath): os.makedirs(mybasepath) for stage in ['incoming', 'ACCEPT', 'REJECT', 'HOLD', 'DEFER' ]: if not os.path.isdir(os.path.join(mybasepath, stage)): os.makedirs(os.path.join(mybasepath, stage)) log.debug(_("Resource Management called for %r, %r") % (args, kw), level=8) auth = Auth() imap = IMAP() # TODO: Test for correct call. filepath = args[0] if kw.has_key('stage'): log.debug( _("Issuing callback after processing to stage %s") % ( kw['stage'] ), level=8 ) log.debug(_("Testing cb_action_%s()") % (kw['stage']), level=8) if hasattr(modules, 'cb_action_%s' % (kw['stage'])): log.debug( _("Attempting to execute cb_action_%s()") % (kw['stage']), level=8 ) exec( 'modules.cb_action_%s(%r, %r)' % ( kw['stage'], 'resources', filepath ) ) return filepath else: # Move to incoming new_filepath = os.path.join( mybasepath, 'incoming', os.path.basename(filepath) ) if not filepath == new_filepath: log.debug("Renaming %r to %r" % (filepath, new_filepath)) os.rename(filepath, new_filepath) filepath = new_filepath # parse full message message = Parser().parse(open(filepath, 'r')) # invalid message, skip if not message.get('X-Kolab-To'): return filepath recipients = [address for displayname,address in getaddresses(message.get_all('X-Kolab-To'))] sender_email = [address for displayname,address in getaddresses(message.get_all('X-Kolab-From'))][0] any_itips = False any_resources = False possibly_any_resources = False reference_uid = None # An iTip message may contain multiple events. Later on, test if the message # is an iTip message by checking the length of this list. try: itip_events = events_from_message(message, ['REQUEST', 'REPLY', 'CANCEL']) except Exception, e: log.error(_("Failed to parse iTip events from message: %r" % (e))) itip_events = [] if not len(itip_events) > 0: log.info( _("Message is not an iTip message or does not contain any " + \ "(valid) iTip.") ) else: any_itips = True log.debug( _("iTip events attached to this message contain the " + \ "following information: %r") % (itip_events), - level=9 + level=8 ) if any_itips: # See if any iTip actually allocates a resource. if len([x['resources'] for x in itip_events if x.has_key('resources')]) > 0 \ or len([x['attendees'] for x in itip_events if x.has_key('attendees')]) > 0: possibly_any_resources = True if possibly_any_resources: auth.connect() for recipient in recipients: # extract reference UID from recipients like resource+UID@domain.org if re.match('.+\+[A-Za-z0-9=/-]+@', recipient): try: (prefix, host) = recipient.split('@') (local, uid) = prefix.split('+') reference_uid = base64.b64decode(uid, '-/') recipient = local + '@' + host except: continue if not len(resource_record_from_email_address(recipient)) == 0: resource_recipient = recipient any_resources = True if any_resources: if not any_itips: log.debug(_("Not an iTip message, but sent to resource nonetheless. Reject message"), level=5) reject(filepath) return False else: # Continue. Resources and iTips. We like. pass else: if not any_itips: log.debug(_("No itips, no resources, pass along %r") % (filepath), level=5) return filepath else: log.debug(_("iTips, but no resources, pass along %r") % (filepath), level=5) return filepath # A simple list of merely resource entry IDs that hold any relevance to the # iTip events resource_dns = resource_records_from_itip_events(itip_events, resource_recipient) # check if resource attendees match the envelope recipient if len(resource_dns) == 0: log.info(_("No resource attendees matching envelope recipient %s, Reject message") % (resource_recipient)) log.debug("%r" % (itip_events), level=8) reject(filepath) return False # Get the resource details, which includes details on the IMAP folder # This may append resource collection members to recource_dns resources = get_resource_records(resource_dns) log.debug(_("Resources: %r; %r") % (resource_dns, resources), level=8) imap.connect() done = False receiving_resource = resources[resource_dns[0]] for itip_event in itip_events: if itip_event['method'] == 'REPLY': done = True # find initial reservation referenced by the reply if reference_uid: (event, master) = find_existing_event(reference_uid, itip_event['recurrence-id'], receiving_resource) log.debug(_("iTip REPLY to %r, %r; matches %r") % (reference_uid, itip_event['recurrence-id'], type(event)), level=8) if event: try: sender_attendee = itip_event['xml'].get_attendee_by_email(sender_email) owner_reply = sender_attendee.get_participant_status() - log.debug(_("Sender Attendee: %r => %r") % (sender_attendee, owner_reply), level=9) + log.debug(_("Sender Attendee: %r => %r") % (sender_attendee, owner_reply), level=8) except Exception, e: log.error(_("Could not find envelope sender attendee: %r") % (e)) continue # compare sequence number to avoid outdated replies if not itip_event['sequence'] == event.get_sequence(): log.info(_("The iTip reply sequence (%r) doesn't match the referred event version (%r). Ignoring.") % ( itip_event['sequence'], event.get_sequence() )) continue # forward owner response comment comment = itip_event['xml'].get_comment() if comment: event.set_comment(str(comment)) _itip_event = dict(xml=event, uid=event.get_uid(), _master=master) _itip_event['recurrence-id'] = event.get_recurrence_id() if owner_reply == kolabformat.PartAccepted: event.set_status(kolabformat.StatusConfirmed) accept_reservation_request(_itip_event, receiving_resource, confirmed=True) elif owner_reply == kolabformat.PartDeclined: decline_reservation_request(_itip_event, receiving_resource) else: log.info(_("Invalid response (%r) received from resource owner for event %r") % ( sender_attendee.get_participant_status(True), reference_uid )) else: log.info(_("Event referenced by this REPLY (%r) not found in resource calendar") % (reference_uid)) else: log.info(_("No event reference found in this REPLY. Ignoring.")) # exit for-loop break # else: try: receiving_attendee = itip_event['xml'].get_attendee_by_email(receiving_resource['mail']) - log.debug(_("Receiving Resource: %r; %r") % (receiving_resource, receiving_attendee), level=9) + log.debug(_("Receiving Resource: %r; %r") % (receiving_resource, receiving_attendee), level=8) except Exception, e: log.error(_("Could not find envelope attendee: %r") % (e)) continue # ignore updates and cancellations to resource collections who already delegated the event if len(receiving_attendee.get_delegated_to()) > 0 or receiving_attendee.get_role() == kolabformat.NonParticipant: done = True log.debug(_("Recipient %r is non-participant, ignoring message") % (receiving_resource['mail']), level=8) # process CANCEL messages if not done and itip_event['method'] == "CANCEL": for resource in resource_dns: if resources[resource]['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()] \ and resources[resource].has_key('kolabtargetfolder'): (event, master) = find_existing_event(itip_event['uid'], itip_event['recurrence-id'], resources[resource]) # remove entire event if event and master is None: log.debug(_("Cancellation for entire event %r: deleting") % (itip_event['uid']), level=8) delete_resource_event(itip_event['uid'], resources[resource], event._msguid) # just cancel one single occurrence: add exception with status=cancelled elif master is not None: log.debug(_("Cancellation for a single occurrence %r of %r: updating...") % (itip_event['recurrence-id'], itip_event['uid']), level=8) event.set_status('CANCELLED') event.set_transparency(True) _itip_event = dict(xml=event, uid=event.get_uid(), _master=master) _itip_event['recurrence-id'] = event.get_recurrence_id() save_resource_event(_itip_event, resources[resource]) done = True if done: os.unlink(filepath) cleanup() return # do the magic for the receiving attendee (available_resource, itip_event) = check_availability(itip_events, resource_dns, resources, receiving_attendee) reject = False resource = None original_resource = None # accept reservation if available_resource is not None: if available_resource['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()]: # check if reservation was delegated if available_resource['mail'] != receiving_resource['mail'] and receiving_attendee.get_participant_status() == kolabformat.PartDelegated: original_resource = receiving_resource resource = available_resource else: # This must have been a resource collection originally. # We have inserted the reference to the original resource # record in 'memberof'. if available_resource.has_key('memberof'): original_resource = resources[available_resource['memberof']] if original_resource['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()]: # # Delegate: # - delegator: the original resource collection # - delegatee: the target resource # itip_event['xml'].delegate(original_resource['mail'], available_resource['mail'], available_resource['cn']) # set delegator to NON-PARTICIPANT and RSVP=FALSE delegator = itip_event['xml'].get_attendee_by_email(original_resource['mail']) delegator.set_role(kolabformat.NonParticipant) delegator.set_rsvp(False) log.debug(_("Delegate invitation for resource collection %r to %r") % (original_resource['mail'], available_resource['mail']), level=8) resource = available_resource # Look for ACT_REJECT policy if resource is not None: invitationpolicy = get_resource_invitationpolicy(resource) - log.debug(_("Apply invitation policies %r") % (invitationpolicy), level=9) + log.debug(_("Apply invitation policies %r") % (invitationpolicy), level=8) if invitationpolicy is not None: for policy in invitationpolicy: if policy & ACT_REJECT: reject = True break if resource is not None and not reject: log.debug(_("Accept invitation for individual resource %r / %r") % (resource['dn'], resource['mail']), level=8) accept_reservation_request(itip_event, resource, original_resource, False, invitationpolicy) else: resource = resources[resource_dns[0]] # this is the receiving resource record log.debug(_("Decline invitation for individual resource %r / %r") % (resource['dn'], resource['mail']), level=8) decline_reservation_request(itip_event, resource) cleanup() os.unlink(filepath) def heartbeat(lastrun): global imap # run archival job every hour only now = int(time.time()) if lastrun == 0 or now - heartbeat._lastrun < 3600: return log.debug(_("module_resources.heartbeat(%d)") % (heartbeat._lastrun), level=8) # get a list of resource records from LDAP auth = Auth() auth.connect() resource_dns = auth.find_resource('*') # Remove referrals resource_dns = [dn for dn in resource_dns if dn is not None] # filter by resource_base_dn resource_base_dn = conf.get('ldap', 'resource_base_dn', None) if resource_base_dn is not None: resource_dns = [dn for dn in resource_dns if resource_base_dn in dn] if len(resource_dns) > 0: imap = IMAP() imap.connect() for resource_dn in resource_dns: resource_attrs = auth.get_entry_attributes(None, resource_dn, ['kolabtargetfolder']) if resource_attrs.has_key('kolabtargetfolder'): try: expunge_resource_calendar(resource_attrs['kolabtargetfolder']) except Exception, e: log.error(_("Expunge resource calendar for %s (%s) failed: %r") % ( resource_dn, resource_attrs['kolabtargetfolder'], e )) imap.disconnect() auth.disconnect() heartbeat._lastrun = now heartbeat._lastrun = 0 def expunge_resource_calendar(mailbox): """ Cleanup routine to remove events older than 100 days from the given resource calendar """ global imap days = int(conf.get('wallace', 'resource_calendar_expire_days')) now = datetime.datetime.now(tzlocal()) expire_date = now - datetime.timedelta(days=days) log.debug( _("Expunge events in resource folder %r older than %d days") % (mailbox, days), level=8 ) # might raise an exception, let that bubble targetfolder = imap.folder_quote(mailbox) imap.set_acl(targetfolder, conf.get(conf.get('kolab', 'imap_backend'), 'admin_login'), "lrswipkxtecda") imap.imap.m.select(targetfolder) typ, data = imap.imap.m.search(None, 'UNDELETED') for num in data[0].split(): log.debug( _("Fetching message ID %r from folder %r") % (num, mailbox), - level=9 + level=8 ) typ, data = imap.imap.m.fetch(num, '(RFC822)') event_message = message_from_string(data[0][1]) try: event = event_from_message(message_from_string(data[0][1])) except Exception, e: log.error(_("Failed to parse event from message %s/%s: %r") % (mailbox, num, e)) continue if event: dt_end = to_dt(event.get_end()) # consider recurring events and get real end date if event.is_recurring(): dt_end = to_dt(event.get_last_occurrence()) if dt_end is None: # skip if recurring forever continue if dt_end and dt_end < expire_date: age = now - dt_end log.debug(_("Flag event %s from message %s/%s as deleted (age = %d days)") % (event.uid, mailbox, num, age.days), level=8) imap.imap.m.store(num, '+FLAGS', '\\Deleted') imap.imap.m.expunge() def check_availability(itip_events, resource_dns, resources, receiving_attendee=None): """ For each resource, determine if any of the events in question are in conflict. """ # Store the (first) conflicting event(s) alongside the resource information. start = time.time() num_messages = 0 available_resource = None for resource in resources.keys(): # skip this for resource collections if not resources[resource].has_key('kolabtargetfolder'): continue # sets the 'conflicting' flag and adds a list of conflicting events found try: num_messages += read_resource_calendar(resources[resource], itip_events) except Exception, e: log.error(_("Failed to read resource calendar for %r: %r") % (resource, e)) end = time.time() - log.debug(_("start: %r, end: %r, total: %r, messages: %d") % (start, end, (end-start), num_messages), level=9) + log.debug(_("start: %r, end: %r, total: %r, messages: %d") % (start, end, (end-start), num_messages), level=8) # For each resource (collections are first!) # check conflicts and either accept or decline the reservation request for resource in resource_dns: - log.debug(_("Polling for resource %r") % (resource), level=9) + log.debug(_("Polling for resource %r") % (resource), level=8) if not resources.has_key(resource): - log.debug(_("Resource %r has been popped from the list") % (resource), level=9) + log.debug(_("Resource %r has been popped from the list") % (resource), level=8) continue if not resources[resource].has_key('conflicting_events'): - log.debug(_("Resource is a collection"), level=9) + log.debug(_("Resource is a collection"), level=8) # check if there are non-conflicting collection members conflicting_members = [x for x in resources[resource]['uniquemember'] if resources[x]['conflict']] # found at least one non-conflicting member, remove the conflicting ones and continue if len(conflicting_members) < len(resources[resource]['uniquemember']): for member in conflicting_members: resources[resource]['uniquemember'] = [x for x in resources[resource]['uniquemember'] if x != member] del resources[member] log.debug(_("Removed conflicting resources from %r: (%r) => %r") % ( resource, conflicting_members, resources[resource]['uniquemember'] - ), level=9) + ), level=8) else: # TODO: shuffle existing bookings of collection members in order # to make one available for the requested time pass continue if len(resources[resource]['conflicting_events']) > 0: - log.debug(_("Conflicting events: %r for resource %r") % (resources[resource]['conflicting_events'], resource), level=9) + log.debug(_("Conflicting events: %r for resource %r") % (resources[resource]['conflicting_events'], resource), level=8) done = False # This is the event being conflicted with! for itip_event in itip_events: # do not re-assign single occurrences to another resource if itip_event['recurrence-id'] is not None: continue # Now we have the event that was conflicting if resources[resource]['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()]: # this resource initially was delegated from a collection ? if receiving_attendee and receiving_attendee.get_email() == resources[resource]['mail'] \ and len(receiving_attendee.get_delegated_from()) > 0: for delegator in receiving_attendee.get_delegated_from(): collection_data = get_resource_collection(delegator.email()) if collection_data is not None: # check if another collection member is available (available_resource, dummy) = check_availability(itip_events, collection_data[0], collection_data[1]) break if available_resource is not None: log.debug(_("Delegate to another resource collection member: %r to %r") % \ (resources[resource]['mail'], available_resource['mail']), level=8) # set this new resource as delegate for the receiving_attendee itip_event['xml'].delegate(resources[resource]['mail'], available_resource['mail'], available_resource['cn']) # set delegator to NON-PARTICIPANT and RSVP=FALSE receiving_attendee.set_role(kolabformat.NonParticipant) receiving_attendee.set_rsvp(False) receiving_attendee.setDelegatedFrom([]) # remove existing_events as we now delegated back to the collection if len(resources[resource]['existing_events']) > 0: for existing in resources[resource]['existing_events']: delete_resource_event(existing.uid, resources[resource], existing._msguid) done = True if done: break else: # No conflicts, go accept for itip_event in itip_events: # directly invited resource if resources[resource]['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()]: available_resource = resources[resource] done = True else: # This must have been a resource collection originally. # We have inserted the reference to the original resource # record in 'memberof'. if resources[resource].has_key('memberof'): original_resource = resources[resources[resource]['memberof']] # Randomly select a target resource from the resource collection. available_resource = resources[original_resource['uniquemember'][random.randint(0,(len(original_resource['uniquemember'])-1))]] done = True if done: break # end for resource in resource_dns: return (available_resource, itip_event) def read_resource_calendar(resource_rec, itip_events): """ Read all booked events from the given resource's calendar and check for conflicts with the given list if itip events """ global imap resource_rec['conflict'] = False resource_rec['conflicting_events'] = [] resource_rec['existing_events'] = [] mailbox = resource_rec['kolabtargetfolder'] log.debug( _("Checking events in resource folder %r") % (mailbox), - level=9 + level=8 ) # set read ACLs for admin user imap.set_acl(mailbox, conf.get(conf.get('kolab', 'imap_backend'), 'admin_login'), "lrs") # might raise an exception, let that bubble imap.imap.m.select(imap.folder_quote(mailbox)) typ, data = imap.imap.m.search(None, 'UNDELETED') num_messages = len(data[0].split()) for num in data[0].split(): # For efficiency, makes the routine non-deterministic if resource_rec['conflict']: continue log.debug( _("Fetching message UID %r from folder %r") % (num, mailbox), - level=9 + level=8 ) typ, data = imap.imap.m.fetch(num, '(UID RFC822)') try: msguid = re.search(r"\WUID (\d+)", data[0][0]).group(1) except Exception, e: log.error(_("No UID found in IMAP response: %r") % (data[0][0])) continue event_message = message_from_string(data[0][1]) try: event = event_from_message(message_from_string(data[0][1])) except Exception, e: log.error(_("Failed to parse event from message %s/%s: %r") % (mailbox, num, e)) continue if event: for itip in itip_events: conflict = check_event_conflict(event, itip) if event.get_uid() == itip['uid']: setattr(event, '_msguid', msguid) if event.is_recurring() or itip['recurrence-id']: resource_rec['existing_master'] = event else: resource_rec['existing_events'].append(event) if conflict: log.info( _("Event %r conflicts with event %r") % ( itip['xml'].get_uid(), event.get_uid() ) ) resource_rec['conflicting_events'].append(event.get_uid()) resource_rec['conflict'] = True return num_messages def find_existing_event(uid, recurrence_id, resource_rec): """ Search the resources's calendar folder for the given event (by UID) """ global imap event = None master = None mailbox = resource_rec['kolabtargetfolder'] - log.debug(_("Searching %r for event %r") % (mailbox, uid), level=9) + log.debug(_("Searching %r for event %r") % (mailbox, uid), level=8) try: imap.imap.m.select(imap.folder_quote(mailbox)) typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid)) except Exception, e: log.error(_("Failed to access resource calendar:: %r") % (e)) return event for num in reversed(data[0].split()): typ, data = imap.imap.m.fetch(num, '(UID RFC822)') try: msguid = re.search(r"\WUID (\d+)", data[0][0]).group(1) except Exception, e: log.error(_("No UID found in IMAP response: %r") % (data[0][0])) continue try: event = event_from_message(message_from_string(data[0][1])) # find instance in a recurring series if recurrence_id and (event.is_recurring() or event.has_exceptions()): master = event event = master.get_instance(recurrence_id) setattr(master, '_msguid', msguid) # return master, even if instance is not found if not event and master.uid == uid: return (event, master) # compare recurrence-id and skip to next message if not matching elif recurrence_id and not xmlutils.dates_equal(recurrence_id, event.get_recurrence_id()): log.debug(_("Recurrence-ID not matching on message %s, skipping: %r != %r") % ( msguid, recurrence_id, event.get_recurrence_id() ), level=8) continue if event is not None: setattr(event, '_msguid', msguid) except Exception, e: log.error(_("Failed to parse event from message %s/%s: %r") % (mailbox, num, e)) event = None master = None continue if event and event.uid == uid: return (event, master) return (event, master) def accept_reservation_request(itip_event, resource, delegator=None, confirmed=False, invitationpolicy=None): """ Accepts the given iTip event by booking it into the resource's calendar. Then set the attendee status of the given resource to ACCEPTED and sends an iTip reply message to the organizer. """ owner = get_resource_owner(resource) confirmation_required = False if not confirmed and owner: if invitationpolicy is None: invitationpolicy = get_resource_invitationpolicy(resource) - log.debug(_("Apply invitation policies %r") % (invitationpolicy), level=9) + log.debug(_("Apply invitation policies %r") % (invitationpolicy), level=8) if invitationpolicy is not None: for policy in invitationpolicy: if policy & ACT_MANUAL and owner['mail']: confirmation_required = True break partstat = 'TENTATIVE' if confirmation_required else 'ACCEPTED' itip_event['xml'].set_transparency(False); itip_event['xml'].set_attendee_participant_status( itip_event['xml'].get_attendee_by_email(resource['mail']), partstat ) saved = save_resource_event(itip_event, resource) log.debug( _("Adding event to %r: %r") % (resource['kolabtargetfolder'], saved), level=8 ) if saved: send_response(delegator['mail'] if delegator else resource['mail'], itip_event, owner) if owner and confirmation_required: send_owner_confirmation(resource, owner, itip_event) elif owner: send_owner_notification(resource, owner, itip_event, saved) def decline_reservation_request(itip_event, resource): """ Set the attendee status of the given resource to DECLINED and send an according iTip reply to the organizer. """ itip_event['xml'].set_attendee_participant_status( itip_event['xml'].get_attendee_by_email(resource['mail']), "DECLINED" ) # update master event if resource.get('existing_master') is not None or itip_event.get('_master') is not None: save_resource_event(itip_event, resource) # remove old copy of the reservation elif resource.get('existing_events', []) and len(resource['existing_events']) > 0: for existing in resource['existing_events']: delete_resource_event(existing.uid, resource, existing._msguid) # delete old event referenced by itip_event (from owner confirmation) elif hasattr(itip_event['xml'], '_msguid'): delete_resource_event(itip_event['xml'].uid, resource, itip_event['xml']._msguid) # send response and notification owner = get_resource_owner(resource) send_response(resource['mail'], itip_event, owner) if owner: send_owner_notification(resource, owner, itip_event, True) def save_resource_event(itip_event, resource): """ Append the given event object to the resource's calendar """ try: save_event = itip_event['xml'] targetfolder = imap.folder_quote(resource['kolabtargetfolder']) # add exception to existing recurring main event if resource.get('existing_master') is not None: save_event = resource['existing_master'] save_event.add_exception(itip_event['xml']) elif itip_event.get('_master') is not None: save_event = itip_event['_master'] save_event.add_exception(itip_event['xml']) # remove old copy of the reservation (also sets ACLs) if resource.has_key('existing_events') and len(resource['existing_events']) > 0: for existing in resource['existing_events']: delete_resource_event(existing.uid, resource, existing._msguid) # delete old version referenced save_event elif hasattr(save_event, '_msguid'): delete_resource_event(save_event.uid, resource, save_event._msguid) else: imap.set_acl(targetfolder, conf.get(conf.get('kolab', 'imap_backend'), 'admin_login'), "lrswipkxtecda") # append new version result = imap.imap.m.append( targetfolder, None, None, save_event.to_message(creator="Kolab Server ").as_string() ) return result except Exception, e: log.error(_("Failed to save event to resource calendar at %r: %r") % ( resource['kolabtargetfolder'], e )) return False def delete_resource_event(uid, resource, msguid=None): """ Removes the IMAP object with the given UID from a resource's calendar folder """ targetfolder = imap.folder_quote(resource['kolabtargetfolder']) try: imap.set_acl(targetfolder, conf.get(conf.get('kolab', 'imap_backend'), 'admin_login'), "lrswipkxtecda") imap.imap.m.select(targetfolder) # delete by IMAP UID if msguid is not None: log.debug(_("Delete resource calendar object from %r by UID %r") % ( targetfolder, msguid ), level=8) imap.imap.m.uid('store', msguid, '+FLAGS', '(\\Deleted)') else: typ, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % uid) log.debug(_("Delete resource calendar object %r in %r: %r") % ( uid, resource['kolabtargetfolder'], data - ), level=9) + ), level=8) for num in data[0].split(): imap.imap.m.store(num, '+FLAGS', '\\Deleted') imap.imap.m.expunge() return True except Exception, e: log.error(_("Failed to delete calendar object %r from folder %r: %r") % ( uid, targetfolder, e )) return False def reject(filepath): new_filepath = os.path.join( mybasepath, 'REJECT', os.path.basename(filepath) ) os.rename(filepath, new_filepath) filepath = new_filepath exec('modules.cb_action_REJECT(%r, %r)' % ('resources',filepath)) def resource_record_from_email_address(email_address): """ Resolves the given email address to a resource entity """ global auth if not auth: auth = Auth() auth.connect() resource_records = [] local_domains = auth.list_domains() if not local_domains == None: local_domains = list(set(local_domains.keys())) if not email_address.split('@')[1] in local_domains: return [] log.debug( _("Checking if email address %r belongs to a resource (collection)") % (email_address), level=8 ) resource_records = auth.find_resource(email_address) if isinstance(resource_records, list): if len(resource_records) > 0: log.debug(_("Resource record(s): %r") % (resource_records), level=8) else: - log.debug(_("No resource (collection) records found for %r") % (email_address), level=9) + log.debug(_("No resource (collection) records found for %r") % (email_address), level=8) elif isinstance(resource_records, basestring): resource_records = [ resource_records ] log.debug(_("Resource record: %r") % (resource_records), level=8) return resource_records def resource_records_from_itip_events(itip_events, recipient_email=None): """ Given a list of itip_events, determine which resources have been invited as attendees and/or resources. """ global auth if not auth: auth = Auth() auth.connect() resource_records = [] - log.debug(_("Raw itip_events: %r") % (itip_events), level=9) + log.debug(_("Raw itip_events: %r") % (itip_events), level=8) attendees_raw = [] for list_attendees_raw in [x for x in [y['attendees'] for y in itip_events if y.has_key('attendees') and isinstance(y['attendees'], list)]]: attendees_raw.extend(list_attendees_raw) for list_attendees_raw in [y['attendees'] for y in itip_events if y.has_key('attendees') and isinstance(y['attendees'], basestring)]: attendees_raw.append(list_attendees_raw) - log.debug(_("Raw set of attendees: %r") % (attendees_raw), level=9) + log.debug(_("Raw set of attendees: %r") % (attendees_raw), level=8) # TODO: Resources are actually not implemented in the format. We reset this # list later. resources_raw = [] for list_resources_raw in [x for x in [y['resources'] for y in itip_events if y.has_key('resources')]]: resources_raw.extend(list_resources_raw) - log.debug(_("Raw set of resources: %r") % (resources_raw), level=9) + log.debug(_("Raw set of resources: %r") % (resources_raw), level=8) # consider organizer (in REPLY messages), too organizers_raw = [re.sub('\+[A-Za-z0-9=/-]+@', '@', str(y['organizer'])) for y in itip_events if y.has_key('organizer')] log.debug(_("Raw set of organizers: %r") % (organizers_raw), level=8) # TODO: We expect the format of an attendee line to literally be: # # ATTENDEE:RSVP=TRUE;ROLE=REQ-PARTICIPANT;MAILTO:lydia.bossers@kolabsys.com # # which makes the attendees_raw contain: # # RSVP=TRUE;ROLE=REQ-PARTICIPANT;MAILTO:lydia.bossers@kolabsys.com # attendees = [x.split(':')[-1] for x in attendees_raw + organizers_raw] # Limit the attendee resources to the one that is actually invited # with the current message. Considering all invited resources would result in # duplicate responses from every iTip message sent to a resource. if recipient_email is not None: attendees = [a for a in attendees if a == recipient_email] for attendee in attendees: log.debug(_("Checking if attendee %r is a resource (collection)") % (attendee), level=8) _resource_records = auth.find_resource(attendee) if isinstance(_resource_records, list): if len(_resource_records) > 0: resource_records.extend(_resource_records) log.debug(_("Resource record(s): %r") % (_resource_records), level=8) else: - log.debug(_("No resource (collection) records found for %r") % (attendee), level=9) + log.debug(_("No resource (collection) records found for %r") % (attendee), level=8) elif isinstance(_resource_records, basestring): resource_records.append(_resource_records) log.debug(_("Resource record: %r") % (_resource_records), level=8) else: log.warning(_("Resource reservation made but no resource records found")) # Escape the non-implementation of the free-form, undefined RESOURCES # list(s) in iTip. if len(resource_records) == 0: # TODO: We don't know how to handle this yet! # We expect the format of an resource line to literally be: # RESOURCES:MAILTO:resource-car@kolabsys.com resources_raw = [] resources = [x.split(':')[-1] for x in resources_raw] # Limit the attendee resources to the one that is actually invited # with the current message. if recipient_email is not None: resources = [a for a in resources if a == recipient_email] for resource in resources: log.debug(_("Checking if resource %r is a resource (collection)") % (resource), level=8) _resource_records = auth.find_resource(resource) if isinstance(_resource_records, list): if len(_resource_records) > 0: resource_records.extend(_resource_records) log.debug(_("Resource record(s): %r") % (_resource_records), level=8) else: log.debug(_("No resource (collection) records found for %r") % (resource), level=8) elif isinstance(_resource_records, basestring): resource_records.append(_resource_records) log.debug(_("Resource record: %r") % (_resource_records), level=8) else: log.warning(_("Resource reservation made but no resource records found")) log.debug(_("The following resources are being referred to in the " + \ "iTip: %r") % (resource_records), level=8) return resource_records def get_resource_records(resource_dns): """ Get the resource details, which includes details on the IMAP folder """ global auth resources = {} for resource_dn in list(set(resource_dns)): # Get the attributes for the record # See if it is a resource collection # If it is, expand to individual resources # If it is not, ... resource_attrs = auth.get_entry_attributes(None, resource_dn, ['*']) resource_attrs['dn'] = resource_dn parse_kolabinvitationpolicy(resource_attrs) if not 'kolabsharedfolder' in [x.lower() for x in resource_attrs['objectclass']]: if resource_attrs.has_key('uniquemember'): if not isinstance(resource_attrs['uniquemember'], list): resource_attrs['uniquemember'] = [ resource_attrs['uniquemember'] ] resources[resource_dn] = resource_attrs for uniquemember in resource_attrs['uniquemember']: member_attrs = auth.get_entry_attributes( None, uniquemember, ['*'] ) if 'kolabsharedfolder' in [x.lower() for x in member_attrs['objectclass']]: member_attrs['dn'] = uniquemember parse_kolabinvitationpolicy(member_attrs, resource_attrs) resources[uniquemember] = member_attrs resources[uniquemember]['memberof'] = resource_dn if not member_attrs.has_key('owner') and resources[resource_dn].has_key('owner'): resources[uniquemember]['owner'] = resources[resource_dn]['owner'] resource_dns.append(uniquemember) else: resources[resource_dn] = resource_attrs return resources def parse_kolabinvitationpolicy(attrs, parent=None): if attrs.has_key('kolabinvitationpolicy'): if not isinstance(attrs['kolabinvitationpolicy'], list): attrs['kolabinvitationpolicy'] = [attrs['kolabinvitationpolicy']] attrs['kolabinvitationpolicy'] = [policy_name_map[p] for p in attrs['kolabinvitationpolicy'] if policy_name_map.has_key(p)] elif isinstance(parent, dict) and parent.has_key('kolabinvitationpolicy'): attrs['kolabinvitationpolicy'] = parent['kolabinvitationpolicy'] def get_resource_collection(email_address): """ """ resource_dns = resource_record_from_email_address(email_address) if len(resource_dns) == 1: resource_attrs = auth.get_entry_attributes(None, resource_dns[0], ['objectclass']) if not 'kolabsharedfolder' in [x.lower() for x in resource_attrs['objectclass']]: resources = get_resource_records(resource_dns) return (resource_dns, resources) return None def get_resource_owner(resource): """ Get this resource's owner record """ global auth if not auth: auth = Auth() auth.connect() owners = [] if resource.has_key('owner'): if not isinstance(resource['owner'], list): owners = [ resource['owner'] ] else: owners = resource['owner'] else: # get owner attribute from collection collections = auth.search_entry_by_attribute('uniquemember', resource['dn']) if not isinstance(collections, list): collections = [ collections ] for dn,collection in collections: if collection.has_key('owner') and isinstance(collection['owner'], list): owners += collection['owner'] elif collection.has_key('owner'): owners.append(collection['owner']) for dn in owners: owner = auth.get_entry_attributes(None, dn, ['cn','mail','telephoneNumber']) if owner is not None: return owner return None def get_resource_invitationpolicy(resource): """ Get this resource's kolabinvitationpolicy configuration """ global auth if not resource.has_key('kolabinvitationpolicy') or resource['kolabinvitationpolicy'] is None: if not auth: auth = Auth() auth.connect() # get kolabinvitationpolicy attribute from collection collections = auth.search_entry_by_attribute('uniquemember', resource['dn']) if not isinstance(collections, list): collections = [ (collections['dn'],collections) ] - log.debug(_("Check collections %r for kolabinvitationpolicy attributes") % (collections), level=9) + log.debug(_("Check collections %r for kolabinvitationpolicy attributes") % (collections), level=8) for dn,collection in collections: # ldap.search_entry_by_attribute() doesn't return the attributes lower-cased if collection.has_key('kolabInvitationPolicy'): collection['kolabinvitationpolicy'] = collection['kolabInvitationPolicy'] if collection.has_key('kolabinvitationpolicy'): parse_kolabinvitationpolicy(collection) resource['kolabinvitationpolicy'] = collection['kolabinvitationpolicy'] break return resource['kolabinvitationpolicy'] if resource.has_key('kolabinvitationpolicy') else None def send_response(from_address, itip_events, owner=None): """ Send the given iCal events as a valid iTip response to the organizer. In case the invited resource coolection was delegated to a concrete resource, this will send an additional DELEGATED response message. """ if isinstance(itip_events, dict): itip_events = [ itip_events ] for itip_event in itip_events: attendee = itip_event['xml'].get_attendee_by_email(from_address) participant_status = itip_event['xml'].get_ical_attendee_participant_status(attendee) # TODO: look-up event organizer in LDAP and change localization to its preferredlanguage message_text = reservation_response_text(participant_status, owner) subject_template = _("Reservation Request for %(summary)s was %(status)s") # Extra actions to take: send delegated reply if participant_status == "DELEGATED": delegatee = [a for a in itip_event['xml'].get_attendees() if from_address in a.get_delegated_from(True)][0] delegated_message_text = _(""" *** This is an automated response, please do not reply! *** Your reservation was delegated to "%s" which is available for the requested time. """) % (delegatee.get_name()) pykolab.itip.send_reply(from_address, itip_event, delegated_message_text, subject=subject_template) # adjust some vars for the regular reply from the delegatee message_text = reservation_response_text(delegatee.get_participant_status(True), owner) from_address = delegatee.get_email() time.sleep(2) pykolab.itip.send_reply(from_address, itip_event, message_text, subject=subject_template) def reservation_response_text(status, owner): message_text = _(""" *** This is an automated response, please do not reply! *** We hereby inform you that your reservation was %s. """) % (participant_status_label(status)) if owner: message_text += _(""" If you have questions about this reservation, please contact %s <%s> %s """) % (owner['cn'], owner['mail'], owner['telephoneNumber'] if owner.has_key('telephoneNumber') else '') return message_text def send_owner_notification(resource, owner, itip_event, success=True): """ Send a reservation notification to the resource owner """ import smtplib from pykolab import utils from email.MIMEText import MIMEText from email.Utils import formatdate # encode unicode strings with quoted-printable from email import charset charset.add_charset('utf-8', charset.SHORTEST, charset.QP) notify = False status = itip_event['xml'].get_attendee_by_email(resource['mail']).get_participant_status(True) invitationpolicy = get_resource_invitationpolicy(resource) if invitationpolicy is not None: for policy in invitationpolicy: # TODO: distingish ACCEPTED / DECLINED status notifications? if policy & COND_NOTIFY and owner['mail']: notify = True break if notify or not success: log.debug( _("Sending booking notification for event %r to %r from %r") % ( itip_event['uid'], owner['mail'], resource['cn'] ), level=8 ) # change gettext language to the preferredlanguage setting of the resource owner if owner.has_key('preferredlanguage'): pykolab.translate.setUserLanguage(owner['preferredlanguage']) message_text = owner_notification_text(resource, owner, itip_event['xml'], success) msg = MIMEText(utils.stripped_message(message_text), _charset='utf-8') msg['To'] = owner['mail'] msg['From'] = resource['mail'] msg['Date'] = formatdate(localtime=True) msg['Subject'] = utils.str2unicode(_('Booking for %s has been %s') % ( resource['cn'], participant_status_label(status) if success else _('failed') )) smtp = smtplib.SMTP("localhost", 10027) if conf.debuglevel > 8: smtp.set_debuglevel(True) try: smtp.sendmail(resource['mail'], owner['mail'], msg.as_string()) except Exception, e: log.error(_("SMTP sendmail error: %r") % (e)) smtp.quit() def owner_notification_text(resource, owner, event, success): organizer = event.get_organizer() status = event.get_attendee_by_email(resource['mail']).get_participant_status(True) if success: message_text = _(""" The resource booking for %(resource)s by %(orgname)s <%(orgemail)s> has been %(status)s for %(date)s. *** This is an automated message, sent to you as the resource owner. *** """) else: message_text = _(""" A reservation request for %(resource)s could not be processed automatically. Please contact %(orgname)s <%(orgemail)s> who requested this resource for %(date)s. Subject: %(summary)s. *** This is an automated message, sent to you as the resource owner. *** """) return message_text % { 'resource': resource['cn'], 'summary': event.get_summary(), 'date': event.get_date_text(), 'status': participant_status_label(status), 'orgname': organizer.name(), 'orgemail': organizer.email() } def send_owner_confirmation(resource, owner, itip_event): """ Send a reservation request to the resource owner for manual confirmation (ACCEPT or DECLINE) This clones the given invtation with a new UID and setting the resource as organizer in order to receive the reply from the owner. """ uid = itip_event['uid'] event = itip_event['xml'] organizer = event.get_organizer() event_attendees = [a.get_displayname() for a in event.get_attendees() if not a.get_cutype() == kolabformat.CutypeResource] log.debug( _("Clone invitation for owner confirmation: %r from %r") % ( itip_event['uid'], event.get_organizer().email() ), level=8 ) # generate new UID and set the resource as organizer (mail, domain) = resource['mail'].split('@') event.set_uid(str(uuid.uuid4())) event.set_organizer(mail + '+' + base64.b64encode(uid, '-/') + '@' + domain, resource['cn']) itip_event['uid'] = event.get_uid() # add resource owner as (the sole) attendee event._attendees = [] event.add_attendee(owner['mail'], owner['cn'], rsvp=True, role=kolabformat.Required, participant_status=kolabformat.PartNeedsAction) # flag this iTip message as confirmation type event.add_custom_property('X-Kolab-InvitationType', 'CONFIRMATION') message_text = _(""" A reservation request for %(resource)s requires your approval! Please either accept or decline this invitation without saving it to your calendar. The reservation request was sent from %(orgname)s <%(orgemail)s>. Subject: %(summary)s. Date: %(date)s Participants: %(attendees)s *** This is an automated message, please don't reply by email. *** """)% { 'resource': resource['cn'], 'orgname': organizer.name(), 'orgemail': organizer.email(), 'summary': event.get_summary(), 'date': event.get_date_text(), 'attendees': ",\n+ ".join(event_attendees) } pykolab.itip.send_request(owner['mail'], itip_event, message_text, subject=_('Booking request for %s requires confirmation') % (resource['cn']), direct=True) diff --git a/wallace/modules.py b/wallace/modules.py index 44bb1e0..1d25b1c 100644 --- a/wallace/modules.py +++ b/wallace/modules.py @@ -1,395 +1,409 @@ # -*- coding: utf-8 -*- # Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com) # # Jeroen van Meeuwen (Kolab Systems) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os import sys import time -import traceback from email import message_from_string from email.message import Message from email.mime.base import MIMEBase from email.mime.message import MIMEMessage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.parser import Parser from email.utils import COMMASPACE from email.utils import formatdate from email.utils import formataddr from email.utils import getaddresses from email.utils import parsedate_tz import smtplib import pykolab from pykolab import constants from pykolab.translate import _ log = pykolab.getLogger('pykolab.wallace') conf = pykolab.getConf() modules = {} def __init__(): # We only want the base path modules_base_path = os.path.dirname(__file__) for modules_path, dirnames, filenames in os.walk(modules_base_path): if not modules_path == modules_base_path: continue for filename in filenames: if filename.startswith('module_') and filename.endswith('.py'): module_name = filename.replace('.py','') name = module_name.replace('module_', '') #print "exec(\"from %s import __init__ as %s_register\"" % (module_name,name) exec("from %s import __init__ as %s_register" % (module_name, name)) exec("%s_register()" % (name)) for dirname in dirnames: register_group(modules_path, dirname) def list_modules(*args, **kw): """ List modules """ __modules = {} for module in modules.keys(): if isinstance(module, tuple): module_group, module = module __modules[module_group] = { module: modules[(module_group,module)] } else: __modules[module] = modules[module] _modules = __modules.keys() _modules.sort() for _module in _modules: if __modules[_module].has_key('function'): # This is a top-level module if not __modules[_module]['description'] == None: print "%-25s - %s" % (_module.replace('_','-'),__modules[_module]['description']) else: print "%-25s" % (_module.replace('_','-')) for _module in _modules: if not __modules[_module].has_key('function'): # This is a nested module print "\n" + _("Module Group: %s") % (_module) + "\n" ___modules = __modules[_module].keys() ___modules.sort() for __module in ___modules: if not __modules[_module][__module]['description'] == None: print "%-4s%-21s - %s" % ('',__module.replace('_','-'),__modules[_module][__module]['description']) else: print "%-4s%-21s" % ('',__module.replace('_','-')) def execute(name, *args, **kw): if not modules.has_key(name): log.error(_("No such module %r in modules %r (1).") % (name, modules)) sys.exit(1) if not modules[name].has_key('function') and \ not modules[name].has_key('group'): log.error(_("No such module %r in modules %r (2).") %(name, modules)) sys.exit(1) try: return modules[name]['function'](*args, **kw) except Exception, errmsg: - log.error(_("Unknown error occurred; %r") % (errmsg)) - log.error("%s" % (traceback.format_exc())) + log.exception(_("Module %r - Unknown error occurred; %r") % (name, errmsg)) def heartbeat(name, *args, **kw): if not modules.has_key(name): log.warning(_("No such module %r in modules %r (1).") % (name, modules)) if modules[name].has_key('heartbeat'): return modules[name]['heartbeat'](*args, **kw) def _sendmail(sender, recipients, msg): # NOTE: Use "127.0.0.1" here for IPv6 (see also the service # definition in master.cf). - smtp = smtplib.SMTP("127.0.0.1", 10027) + + sl = pykolab.logger.StderrToLogger(log) + smtplib.stderr = sl + + smtp = smtplib.SMTP(timeout=5) if conf.debuglevel > 8: - smtp.set_debuglevel(True) + smtp.set_debuglevel(1) + + success = False + retries = 5 - # Not an infinite loop - while True: + while not success and retries > 0: try: - smtp.sendmail( - sender, - recipients, - msg - ) + log.debug(_("Trying to send email via smtplib from %r, to %r") % (sender, recipients), level=8) + smtp.connect("127.0.0.1", 10027) + _response = smtp.sendmail(sender, recipients, msg) + + if len(_response) == 0: + log.debug(_("SMTP sendmail OK"), level=8) + else: + log.debug(_("SMTP sendmail returned: %r") % (_response), level=8) smtp.quit() - return True + success = True + break except smtplib.SMTPServerDisconnected, errmsg: - smtp.quit() - smtp.connect() + log.error("SMTP Server Disconnected Error, %r" % (errmsg)) + + except smtplib.SMTPConnectError, errmsg: + # DEFER + log.error("SMTP Connect Error, %r" % (errmsg)) except smtplib.SMTPDataError, errmsg: # DEFER log.error("SMTP Data Error, %r" % (errmsg)) except smtplib.SMTPHeloError, errmsg: # DEFER log.error("SMTP HELO Error, %r" % (errmsg)) except smtplib.SMTPRecipientsRefused, errmsg: # REJECT, send NDR log.error("SMTP Recipient(s) Refused, %r" % (errmsg)) except smtplib.SMTPSenderRefused, errmsg: # REJECT, send NDR log.error("SMTP Sender Refused, %r" % (errmsg)) except Exception, errmsg: - log.error(_("Unknown error occurred; %r") % (errmsg)) - log.error("%r" % (traceback.format_exc())) + log.exception(_("smtplib - Unknown error occurred: %r") % (errmsg)) + + smtp.quit() + time.sleep(10) + retries -= 1 - return False + return success def cb_action_HOLD(module, filepath): log.info(_("Holding message in queue for manual review (%s by %s)") % (filepath, module)) def cb_action_DEFER(module, filepath): log.info(_("Deferring message in %s (by module %s)") % (filepath, module)) # parse message headers message = Parser().parse(open(filepath, 'r'), True) internal_time = parsedate_tz(message.__getitem__('Date')) internal_time = time.mktime(internal_time[:9]) + internal_time[9] now_time = time.time() delta = now_time - internal_time log.debug(_("The time when the message was sent: %r") % (internal_time), level=8) log.debug(_("The time now: %r") % (now_time), level=8) log.debug(_("The time delta: %r") % (delta), level=8) if delta > 432000: # TODO: Send NDR back to user log.debug(_("Message in file %s older then 5 days, deleting") % (filepath), level=8) os.unlink(filepath) # Alternative method is file age. #Date sent(/var/spool/pykolab/wallace/optout/DEFER/tmpIv7pDl): 'Thu, 08 Mar 2012 11:51:03 +0000' #(2012, 3, 8, 11, 51, 3, 0, 1, -1) # YYYY M D H m s weekday, yearday #log.debug(datetime.datetime(*), level=8) #import os #stat = os.stat(filepath) #fileage = datetime.datetime.fromtimestamp(stat.st_mtime) #now = datetime.datetime.now() #delta = now - fileage #print "file:", filepath, "fileage:", fileage, "now:", now, "delta(seconds):", delta.seconds #if delta.seconds > 1800: ## TODO: Send NDR back to user #log.debug(_("Message in file %s older then 1800 seconds, deleting") % (filepath), level=8) #os.unlink(filepath) def cb_action_REJECT(module, filepath): log.info(_("Rejecting message in %s (by module %s)") % (filepath, module)) # parse message headers message = Parser().parse(open(filepath, 'r'), True) envelope_sender = getaddresses(message.get_all('From', [])) recipients = getaddresses(message.get_all('To', [])) + \ getaddresses(message.get_all('Cc', [])) + \ getaddresses(message.get_all('X-Kolab-To', [])) _recipients = [] for recipient in recipients: if not recipient[0] == '': _recipients.append('%s <%s>' % (recipient[0], recipient[1])) else: _recipients.append('%s' % (recipient[1])) # TODO: Find the preferredLanguage for the envelope_sender user. ndr_message_subject = "Undelivered Mail Returned to Sender" ndr_message_text = _("""This is the email system Wallace at %s. I'm sorry to inform you we could not deliver the attached message to the following recipients: - %s Your message is being delivered to any other recipients you may have sent your message to. There is no need to resend the message to those recipients. """) % ( constants.fqdn, "\n- ".join(_recipients) ) diagnostics = _("""X-Wallace-Module: %s X-Wallace-Result: REJECT """) % ( module ) msg = MIMEMultipart("report") msg['From'] = "MAILER-DAEMON@%s" % (constants.fqdn) msg['To'] = formataddr(envelope_sender[0]) msg['Date'] = formatdate(localtime=True) msg['Subject'] = ndr_message_subject msg.preamble = "This is a MIME-encapsulated message." part = MIMEText(ndr_message_text) part.add_header("Content-Description", "Notification") msg.attach(part) _diag_message = Message() _diag_message.set_payload(diagnostics) part = MIMEMessage(_diag_message, "delivery-status") part.add_header("Content-Description", "Delivery Report") msg.attach(part) # @TODO: here I'm not sure message will contain the whole body # when we used headersonly argument of Parser().parse() above # delete X-Kolab-* headers del message['X-Kolab-From'] del message['X-Kolab-To'] part = MIMEMessage(message) part.add_header("Content-Description", "Undelivered Message") msg.attach(part) result = _sendmail( "MAILER-DAEMON@%s" % (constants.fqdn), [formataddr(envelope_sender[0])], msg.as_string() ) if result: os.unlink(filepath) def cb_action_ACCEPT(module, filepath): log.info(_("Accepting message in %s (by module %s)") % (filepath, module)) log.debug(_("Accepting message in: %r") %(filepath), level=8) # parse message headers message = Parser().parse(open(filepath, 'r'), True) sender = [formataddr(x) for x in getaddresses(message.get_all('X-Kolab-From', []))] recipients = [formataddr(x) for x in getaddresses(message.get_all('X-Kolab-To', []))] log.debug(_("recipients: %r") % (recipients)) # delete X-Kolab-* headers del message['X-Kolab-From'] del message['X-Kolab-To'] result = _sendmail( sender, recipients, # - Make sure we do not send this as binary. # - Second, strip NUL characters - I don't know where they # come from (TODO) # - Third, a character return is inserted somewhere. It # divides the body from the headers - and we don't like (TODO) # @TODO: check if we need Parser().parse() to load the whole message message.as_string() ) if result: os.unlink(filepath) def register_group(dirname, module): modules_base_path = os.path.join(os.path.dirname(__file__), module) modules[module] = {} for modules_path, dirnames, filenames in os.walk(modules_base_path): if not modules_path == modules_base_path: continue for filename in filenames: if filename.startswith('module_') and filename.endswith('.py'): module_name = filename.replace('.py','') name = module_name.replace('module_', '') # TODO: Error recovery from incomplete / incorrect modules. exec( "from %s.%s import __init__ as %s_%s_register" % ( module, module_name, module, name ) ) exec("%s_%s_register()" % (module,name)) def register(name, func, group=None, description=None, aliases=[], heartbeat=None): if not group == None: module = "%s_%s" % (group,name) else: module = name if isinstance(aliases, basestring): aliases = [aliases] if modules.has_key(module): log.fatal(_("Module '%s' already registered") % (module)) sys.exit(1) if callable(func): if group == None: modules[name] = { 'function': func, 'description': description } else: modules[group][name] = { 'function': func, 'description': description } modules[module] = modules[group][name] modules[module]['group'] = group modules[module]['name'] = name for alias in aliases: modules[alias] = { 'function': func, 'description': _("Alias for %s") % (name) } if callable(heartbeat): modules[module]['heartbeat'] = heartbeat