diff --git a/pykolab/imap/__init__.py b/pykolab/imap/__init__.py index 8ea23e7..813d722 100644 --- a/pykolab/imap/__init__.py +++ b/pykolab/imap/__init__.py @@ -1,1254 +1,1257 @@ # -*- 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 logging import re import time import socket import sys from urlparse import urlparse import pykolab from pykolab import utils from pykolab.translate import _ log = pykolab.getLogger('pykolab.imap') conf = pykolab.getConf() class IMAP(object): def __init__(self): # Pool of named IMAP connections, by hostname self._imap = {} # Place holder for the current IMAP connection self.imap = None def cleanup_acls(self, aci_subject): log.info( _("Cleaning up ACL entries for %s across all folders") % ( aci_subject ) ) lm_suffix = "" if len(aci_subject.split('@')) > 1: lm_suffix = "@%s" % (aci_subject.split('@')[1]) shared_folders = self.imap.lm("shared/*%s" % (lm_suffix)) user_folders = self.imap.lm("user/*%s" % (lm_suffix)) # For all folders (shared and user), ... folders = user_folders + shared_folders log.debug(_("Iterating over %d folders") % (len(folders)), level=5) # ... loop through them and ... for folder in folders: try: # ... list the ACL entries acls = self.imap.lam(folder) # For each ACL entry, see if we think it is a current, valid # entry for acl_entry in acls.keys(): # If the key 'acl_entry' does not exist in the dictionary # of valid ACL entries, this ACL entry has got to go. if acl_entry == aci_subject: # Set the ACL to '' (effectively deleting the ACL # entry) log.debug( _( "Removing acl %r for subject %r from folder %r" ) % ( acls[acl_entry], acl_entry, folder ), level=8 ) self.set_acl(folder, acl_entry, '') except Exception, errmsg: log.error( _("Failed to read/set ACL on folder %s: %r") % ( folder, errmsg ) ) def connect(self, uri=None, server=None, domain=None, login=True): """ Connect to the appropriate IMAP backend. Supply a domain (name space) configured in the configuration file as a section, with a setting 'imap_uri' to connect to a domain specific IMAP server, or specify an URI to connect to that particular IMAP server (in that order). Routines sitting behind this will take into account Cyrus IMAP Murder capabilities, brokering actions to take place against the correct server (such as a 'xfer' which needs to happen against the source backend). """ # TODO: We are currently compatible with one IMAP backend technology per # deployment. backend = conf.get('kolab', 'imap_backend') if domain is not None: self.domain = domain if conf.has_section(domain) and conf.has_option(domain, 'imap_backend'): backend = conf.get(domain, 'imap_backend') if uri is None: if conf.has_section(domain) and conf.has_option(domain, 'imap_uri'): uri = conf.get(domain, 'imap_uri') else: self.domain = None scheme = None hostname = None port = None if uri is None: uri = conf.get(backend, 'uri') result = urlparse(uri) if hasattr(result, 'netloc'): scheme = result.scheme if len(result.netloc.split(':')) > 1: hostname = result.netloc.split(':')[0] port = result.netloc.split(':')[1] else: hostname = result.netloc elif hasattr(result, 'hostname'): hostname = result.hostname else: scheme = uri.split(':')[0] (hostname, port) = uri.split('/')[2].split(':') if server is not None: hostname = server if scheme is None or scheme == "": scheme = 'imaps' if port is None: if scheme == "imaps": port = 993 elif scheme == "imap": port = 143 else: port = 993 uri = '%s://%s:%s' % (scheme, hostname, port) # Get the credentials admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') if admin_password is None or admin_password == '': log.error(_("No administrator password is available.")) if hostname not in self._imap: if backend == 'cyrus-imap': import cyrus self._imap[hostname] = cyrus.Cyrus(uri) # Actually connect if login: log.debug(_("Logging on to Cyrus IMAP server %s") % (hostname), level=8) self._imap[hostname].login(admin_login, admin_password) self._imap[hostname].logged_in = True elif backend == 'dovecot': import dovecot self._imap[hostname] = dovecot.Dovecot(uri) # Actually connect if login: log.debug(_("Logging on to Dovecot IMAP server %s") % (hostname), level=8) self._imap[hostname].login(admin_login, admin_password) self._imap[hostname].logged_in = True else: import imaplib self._imap[hostname] = imaplib.IMAP4(hostname, port) # Actually connect if login: log.debug(_("Logging on to generic IMAP server %s") % (hostname), level=8) self._imap[hostname].login(admin_login, admin_password) self._imap[hostname].logged_in = True else: if not login: self.disconnect(hostname) self.connect(uri=uri, login=False) elif login and not hasattr(self._imap[hostname], 'logged_in'): self.disconnect(hostname) self.connect(uri=uri) else: try: if hasattr(self._imap[hostname], 'm'): self._imap[hostname].m.noop() elif hasattr(self._imap[hostname], 'noop') \ and callable(self._imap[hostname].noop): self._imap[hostname].noop() log.debug( _("Reusing existing IMAP server connection to %s") % (hostname), level=8 ) except Exception: log.debug(_("Reconnecting to IMAP server %s") % (hostname), level=8) self.disconnect(hostname) self.connect() # Set the newly created technology specific IMAP library as the current # IMAP connection to be used. self.imap = self._imap[hostname] if hasattr(self.imap, 'm') and hasattr(self.imap.m, 'sock'): self._set_socket_keepalive(self.imap.m.sock) elif hasattr(self.imap, 'sock'): self._set_socket_keepalive(self.imap.sock) def disconnect(self, server=None): if server is None: # No server specified, but make sure self.imap is None anyways if hasattr(self, 'imap'): del self.imap # Empty out self._imap as well for key in self._imap.keys(): del self._imap[key] else: if server in self._imap: del self._imap[server] else: log.warning( _("Called imap.disconnect() on a server that we had no connection to.") ) def create_folder(self, folder_path, server=None, partition=None): folder_path = self.folder_utf7(folder_path) if server is not None: self.connect(server=server) try: self._imap[server].cm(folder_path, partition=partition) return True except Exception: log.error(_("Could not create folder %r on server %r") % (folder_path, server)) else: try: self.imap.cm(folder_path, partition=partition) return True except Exception: log.error(_("Could not create folder %r") % (folder_path)) return False def __getattr__(self, name): if hasattr(self.imap, name): return getattr(self.imap, name) elif hasattr(self.imap, 'm'): if hasattr(self.imap.m, name): return getattr(self.imap.m, name) else: raise AttributeError(_("%r has no attribute %s") % (self, name)) else: raise AttributeError(_("%r has no attribute %s") % (self, name)) + def append(self, folder, message): + return self.imap.m.append(self.folder_utf7(folder), None, None, message) + 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 folder_quote(self, folder): return u'"' + str(folder).strip('"') + '"' def get_metadata(self, folder): """ Obtain all metadata entries on a folder """ metadata = {} _metadata = self.imap.getannotation(self.folder_utf7(folder), '*') for (k, v) in _metadata.items(): metadata[self.folder_utf8(k)] = v return metadata def get_separator(self): if not hasattr(self, 'imap') or self.imap is None: self.connect() if hasattr(self.imap, 'separator'): return self.imap.separator elif hasattr(self.imap, 'm') and hasattr(self.imap.m, 'separator'): return self.imap.m.separator else: return '/' def imap_murder(self): if hasattr(self.imap, 'murder') and self.imap.murder: return True else: return False def namespaces(self): """ Obtain the namespaces. Returns a tuple of: (str(personal) [, str(other users) [, list(shared)]]) """ _personal = None _other_users = None _shared = None (_response, _namespaces) = self.imap.m.namespace() if len(_namespaces) == 1: _namespaces = _namespaces[0] _namespaces = re.split(r"\)\)\s\(\(", _namespaces) if len(_namespaces) >= 3: _shared = [] _shared.append(' '.join(_namespaces[2].replace('((', '').replace('))', '').split()[:-1]).replace('"', '')) if len(_namespaces) >= 2: _other_users = ' '.join(_namespaces[1].replace('((', '').replace('))', '').split()[:-1]).replace('"', '') if len(_namespaces) >= 1: _personal = _namespaces[0].replace('((', '').replace('))', '').split()[0].replace('"', '') return (_personal, _other_users, _shared) def set_acl(self, folder, identifier, acl): """ Set an ACL entry on a folder. """ _acl = '' short_rights = { 'all': 'lrsedntxakcpiw', 'append': 'wip', 'full': 'lrswipkxtecdn', 'read': 'lrs', 'read-only': 'lrs', 'read-write': 'lrswitedn', 'post': 'p', 'semi-full': 'lrswit', 'write': 'lrswite', } if acl in short_rights: acl = short_rights[acl] else: for char in acl: if char in "-+": continue if not char in short_rights['all']: log.error(_("Invalid access identifier %r for subject %r") % (acl, identifier)) return False # Special treatment for '-' and '+' characters if '+' in acl or '-' in acl: acl_map = { 'set': '', 'subtract': '', 'add': '' } mode = 'set' for char in acl: if char == '-': mode = 'subtract' continue if char == '+': mode = 'add' continue acl_map[mode] += char current_acls = self.imap.lam(self.folder_utf7(folder)) for current_acl in current_acls.keys(): if current_acl == identifier: _acl = current_acls[current_acl] break _acl = _acl + acl_map['set'] + acl_map['add'] _acl = [x for x in _acl.split() if x not in acl_map['subtract'].split()] acl = ''.join(list(set(_acl))) try: self.imap.sam(self.folder_utf7(folder), identifier, acl) except Exception as errmsg: log.error( _("Could not set ACL for %s on folder %s: %r") % ( identifier, folder, errmsg ) ) def set_metadata(self, folder, metadata_path, metadata_value, shared=True): """ Set a metadata entry on a folder """ if metadata_path.startswith('/shared/'): shared = True metadata_path = metadata_path.replace('/shared/', '/') elif metadata_path.startswith('/private/'): shared = False metadata_path = metadata_path.replace('/private/', '/') self.imap._setannotation(self.folder_utf7(folder), metadata_path, metadata_value, shared) def shared_folder_create(self, folder_path, server=None): """ Create a shared folder. """ folder_name = "shared%s%s" % (self.get_separator(), folder_path) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] log.info(_("Creating new shared folder %s") % (folder_name)) self.create_folder(folder_name, server) def shared_folder_exists(self, folder_path): """ Check if a shared mailbox exists. """ folder_name = 'shared%s%s' % (self.get_separator(), folder_path) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] return self.has_folder(folder_name) def shared_folder_rename(self, old, new): if not self.has_folder(old): log.error("Shared Folder rename error: Folder %s does not exist" % (old)) return if self.has_folder(new): log.error("Shared Folder rename error: Folder %s already exists" % (new)) return self.imap._rename(old, new) def shared_folder_set_type(self, folder_path, folder_type): folder_name = 'shared%s%s' % (self.get_separator(), folder_path) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] self.set_metadata(folder_name, '/shared/vendor/kolab/folder-type', folder_type) def shared_mailbox_create(self, mailbox_base_name, server=None): """ Create a shared folder. """ self.connect() folder_name = "shared%s%s" % (self.get_separator(), mailbox_base_name) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] log.info(_("Creating new shared folder %s") %(mailbox_base_name)) self.create_folder(folder_name, server) def shared_mailbox_exists(self, mailbox_base_name): """ Check if a shared mailbox exists. """ self.connect() folder_name = "shared%s%s" % (self.get_separator(), mailbox_base_name) # Correct folder_path being supplied with "shared/shared/" for example if folder_name.startswith("shared%s" % (self.get_separator()) * 2): folder_name = folder_name[7:] return self.has_folder(folder_name) def user_mailbox_create(self, mailbox_base_name, server=None): """ Create a user mailbox. Returns the full path to the new mailbox folder. """ # TODO: Whether or not to lowercase the mailbox name is really up to the # IMAP server setting username_tolower (normalize_uid, lmtp_downcase_rcpt). self.connect() if not mailbox_base_name == mailbox_base_name.lower(): log.warning(_("Downcasing mailbox name %r") % (mailbox_base_name)) mailbox_base_name = mailbox_base_name.lower() folder_name = "user%s%s" % (self.get_separator(), mailbox_base_name) log.info(_("Creating new mailbox for user %s") %(mailbox_base_name)) success = self._create_folder_waiting(folder_name, server) if not success: log.error(_("Could not create the mailbox for user %s, aborting." % (mailbox_base_name))) return False _additional_folders = None if not hasattr(self, 'domain'): self.domain = None if self.domain is None and len(mailbox_base_name.split('@')) > 1: self.domain = mailbox_base_name.split('@')[1] if not self.domain is None: if conf.has_option(self.domain, "autocreate_folders"): _additional_folders = conf.get_raw( self.domain, "autocreate_folders" ) else: from pykolab.auth import Auth auth = Auth() auth.connect() domains = auth.list_domains(self.domain) auth.disconnect() if len(domains.keys()) > 0: if self.domain in domains: primary = domains[self.domain] if conf.has_option(primary, "autocreate_folders"): _additional_folders = conf.get_raw( primary, "autocreate_folders" ) if _additional_folders is None: if conf.has_option('kolab', "autocreate_folders"): _additional_folders = conf.get_raw( 'kolab', "autocreate_folders" ) additional_folders = conf.plugins.exec_hook( "create_user_folders", kw={ 'folder': folder_name, 'additional_folders': _additional_folders } ) if additional_folders is not None: self.user_mailbox_create_additional_folders( mailbox_base_name, additional_folders ) if not self.domain is None: 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': mailbox_base_name } ) return folder_name def user_mailbox_create_additional_folders(self, user, additional_folders): log.debug( _("Creating additional folders for user %s") % (user), level=8 ) backend = conf.get('kolab', 'imap_backend') admin_login = conf.get(backend, 'admin_login') admin_password = conf.get(backend, 'admin_password') folder = 'user%s%s' % (self.get_separator(), user) if self.imap_murder(): server = self.user_mailbox_server(folder) else: server = None success = False last_log = time.time() while not success: try: self.disconnect() self.connect(login=False, server=server) self.login_plain(admin_login, admin_password, user) (personal, other, shared) = self.namespaces() success = True except Exception as errmsg: if time.time() - last_log > 5 and self.imap_murder(): log.debug(_("Waiting for the Cyrus murder to settle... %r") % (errmsg)) last_log = time.time() if conf.debuglevel > 8: import traceback traceback.print_exc() time.sleep(0.5) for additional_folder in additional_folders.keys(): _add_folder = {} folder_name = additional_folder if not folder_name.startswith(personal): log.error(_("Correcting additional folder name from %r to %r") % (folder_name, "%s%s" % (personal, folder_name))) folder_name = "%s%s" % (personal, folder_name) success = self._create_folder_waiting(folder_name) if not success: log.warning(_("Failed to create folder: %s") % (folder_name)) continue if "annotations" in additional_folders[additional_folder]: for annotation in additional_folders[additional_folder]["annotations"].keys(): self.set_metadata( folder_name, "%s" % (annotation), "%s" % (additional_folders[additional_folder]["annotations"][annotation]) ) if "acls" in additional_folders[additional_folder]: for acl in additional_folders[additional_folder]["acls"].keys(): self.set_acl( folder_name, "%s" % (acl), "%s" % (additional_folders[additional_folder]["acls"][acl]) ) if len(user.split('@')) > 1: localpart = user.split('@')[0] domain = user.split('@')[1] domain_suffix = "@%s" % (domain) else: localpart = user domain = None domain_suffix = "" if domain is not None: 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 = None log.debug(_("Subscribing user to the additional folders"), level=8) _tests = [] # Subscribe only to personal folders (personal, other, shared) = self.namespaces() if other is not None: _tests.append(other) if shared is not None: for _shared in shared: _tests.append(_shared) log.debug(_("Using the following tests for folder subscriptions:"), level=8) for _test in _tests: log.debug(_(" %r") % (_test), level=8) for _folder in self.lm(): log.debug(_("Folder %s") % (_folder), level=8) _subscribe = True for _test in _tests: if not _subscribe: continue if _folder.startswith(_test): _subscribe = False if _subscribe: log.debug(_("Subscribing %s to folder %s") % (user, _folder), level=8) try: self.subscribe(_folder) except Exception as errmsg: log.error(_("Subscribing %s to folder %s failed: %r") % (user, _folder, errmsg)) self.logout() self.connect(domain=self.domain) for additional_folder in additional_folders.keys(): if additional_folder.startswith(personal) and not personal == '': folder_name = additional_folder.replace(personal, '') else: folder_name = additional_folder folder_name = "user%s%s%s%s%s" % ( self.get_separator(), localpart, self.get_separator(), folder_name, domain_suffix ) if "quota" in additional_folders[additional_folder]: try: self.imap.sq( folder_name, additional_folders[additional_folder]['quota'] ) except Exception as errmsg: log.error(_("Could not set quota on %s") % (additional_folder)) if "partition" in additional_folders[additional_folder]: partition = additional_folders[additional_folder]["partition"] try: self.imap._rename(folder_name, folder_name, partition) except: log.error(_("Could not rename %s to reside on partition %s") % (folder_name, partition)) def _create_folder_waiting(self, folder_name, server=None): """ Create a folder and wait to make sure it exists """ created = False try: max_tries = 10 while not created and max_tries > 0: created = self.create_folder(folder_name, server) if not created: self.disconnect() max_tries -= 1 time.sleep(1) self.connect() # In a Cyrus IMAP Murder topology, wait for the murder to have settled if created and self.imap_murder(): success = False last_log = time.time() reconnect_counter = 0 while not success: success = self.has_folder(folder_name) if not success: if time.time() - last_log > 5: reconnect_counter += 1 log.info(_("Waiting for the Cyrus IMAP Murder to settle...")) if reconnect_counter == 6: log.warning(_("Waited for 15 seconds, going to reconnect")) reconnect_counter = 0 self.disconnect() self.connect() last_log = time.time() time.sleep(0.5) except: if conf.debuglevel > 8: import traceback traceback.print_exc() return created def user_mailbox_delete(self, mailbox_base_name): """ Delete a user mailbox. """ self.connect() folder = "user%s%s" %(self.get_separator(), mailbox_base_name) self.delete_mailfolder(folder) self.cleanup_acls(mailbox_base_name) def user_mailbox_exists(self, mailbox_base_name): """ Check if a user mailbox exists. """ self.connect() if not mailbox_base_name == mailbox_base_name.lower(): log.warning(_("Downcasing mailbox name %r") % (mailbox_base_name)) mailbox_base_name = mailbox_base_name.lower() return self.has_folder('user%s%s' %(self.get_separator(), mailbox_base_name)) def user_mailbox_quota(self, mailbox_quota): pass def user_mailbox_rename(self, old_name, new_name, partition=None): self.connect() old_name = "user%s%s" % (self.get_separator(), old_name) new_name = "user%s%s" % (self.get_separator(), new_name) if old_name == new_name and partition is None: return if not self.has_folder(old_name): log.error(_("INBOX folder to rename (%s) does not exist") % (old_name)) if not self.has_folder(new_name) or not partition is None: log.info(_("Renaming INBOX from %s to %s") % (old_name, new_name)) try: self.imap.rename(old_name, new_name, partition) except: log.error(_("Could not rename INBOX folder %s to %s") % (old_name, new_name)) else: log.warning(_("Moving INBOX folder %s won't succeed as target folder %s already exists") % (old_name, new_name)) def user_mailbox_server(self, mailbox): server = self.imap.find_mailfolder_server(mailbox.lower()).lower() log.debug(_("Server for mailbox %r is %r") % (mailbox, server), level=8) return server def has_folder(self, folder): """ Check if the environment has a folder named folder. """ folders = self.imap.lm(self.folder_utf7(folder)) log.debug(_("Looking for folder '%s', we found folders: %r") % (folder, [self.folder_utf8(x) for x in folders]), level=8) # Greater then one, this folder may have subfolders. if len(folders) > 0: return True else: return False def _set_socket_keepalive(self, sock): sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) with open('/proc/sys/net/ipv4/tcp_keepalive_time', 'r') as f: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, (int)(f.read())) with open('/proc/sys/net/ipv4/tcp_keepalive_intvl', 'r') as f: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, (int)(f.read())) with open('/proc/sys/net/ipv4/tcp_keepalive_probes', 'r') as f: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, (int)(f.read())) def _set_kolab_mailfolder_acls(self, acls, folder=None, update=False): # special case, folder has no ACLs assigned and update was requested, # remove all existing ACL entries if update is True and isinstance(acls, list) and len(acls) == 0: acls = self.list_acls(folder) for subject in acls: log.debug( _("Removing ACL rights %s for subject %s on folder " + \ "%s") % (acls[subject], subject, folder), level=8) self.set_acl(folder, subject, '') return if isinstance(acls, basestring): acls = [ acls ] old_acls = None for acl in acls: exec("acl = %s" % (acl)) subject = acl[0] rights = acl[1] if len(acl) == 3: epoch = acl[2] else: epoch = (int)(time.time()) + 3600 # update mode, check existing entries if update is True: if old_acls is None: old_acls = self.list_acls(folder) for old_subject in old_acls: old_acls[old_subject] = old_acls[old_subject] if subject in old_acls: old_acls[subject] = None if epoch > (int)(time.time()): log.debug( _("Setting ACL rights %s for subject %s on folder " + \ "%s") % (rights, subject, folder), level=8) self.set_acl( folder, "%s" % (subject), "%s" % (rights) ) else: log.debug( _("Removing ACL rights %s for subject %s on folder " + \ "%s") % (rights, subject, folder), level=8) self.set_acl( folder, "%s" % (subject), "" ) # update mode, unset removed ACL entries if old_acls is not None: for subject in old_acls: if old_acls[subject] is not None: log.debug( _("Removing ACL rights %s for subject %s on folder " + \ "%s") % (old_acls[subject], subject, folder), level=8) self.set_acl(folder, subject, '') pass """ Blah functions """ def move_user_folders(self, users=[], domain=None): for user in users: if type(user) == dict: if 'old_mail' in user: inbox = "user/%s" % (user['mail']) old_inbox = "user/%s" % (user['old_mail']) if self.has_folder(old_inbox): log.debug(_("Found old INBOX folder %s") % (old_inbox), level=8) if not self.has_folder(inbox): log.info(_("Renaming INBOX from %s to %s") % (old_inbox, inbox)) self.imap.rename(old_inbox, inbox) self.inbox_folders.append(inbox) else: log.warning(_("Moving INBOX folder %s won't succeed as target folder %s already exists") % (old_inbox, inbox)) else: log.debug(_("Did not find old folder user/%s to rename") % (user['old_mail']), level=8) else: log.debug(_("Value for user is not a dictionary"), level=8) def set_quota(self, folder, quota): i = 0 while i < 10: try: self.imap._setquota(folder, quota) i = 10 except: self.disconnect() self.connect() i += 1 def set_user_folder_quota(self, users=[], primary_domain=None, secondary_domain=[], folders=[]): """ Sets the quota in IMAP using the authentication and authorization database 'quota' attribute for the users listed in parameter 'users' """ if conf.has_option(primary_domain, 'quota_attribute'): _quota_attr = conf.get(primary_domain, 'quota_attribute') else: auth_mechanism = conf.get('kolab', 'auth_mechanism') _quota_attr = conf.get(auth_mechanism, 'quota_attribute') _inbox_folder_attr = conf.get('cyrus-sasl', 'result_attribute') default_quota = auth.domain_default_quota(primary_domain) if default_quota == "" or default_quota is None: default_quota = 0 if len(users) == 0: users = auth.list_users(primary_domain) for user in users: quota = None if type(user) == dict: if _quota_attr in user: if type(user[_quota_attr]) == list: quota = user[_quota_attr].pop(0) elif type(user[_quota_attr]) == str: quota = user[_quota_attr] else: _quota = auth.get_user_attribute(primary_domain, user, _quota_attr) if _quota is None: quota = 0 else: quota = _quota if _inbox_folder_attr not in user: continue else: if type(user[_inbox_folder_attr]) == list: folder = "user/%s" % user[_inbox_folder_attr].pop(0) elif type(user[_inbox_folder_attr]) == str: folder = "user/%s" % user[_inbox_folder_attr] elif type(user) == str: quota = auth.get_user_attribute(user, _quota_attr) folder = "user/%s" % (user) folder = folder.lower() try: (used, current_quota) = self.imap.lq(folder) except: # TODO: Go in fact correct the quota. log.warning(_("Cannot get current IMAP quota for folder %s") % (folder)) used = 0 current_quota = 0 new_quota = conf.plugins.exec_hook("set_user_folder_quota", kw={ 'used': used, 'current_quota': current_quota, 'new_quota': (int)(quota), 'default_quota': (int)(default_quota), 'user': user } ) log.debug(_("Quota for %s currently is %s") % (folder, current_quota), level=7) if new_quota is None: continue if not int(new_quota) == int(quota): log.info(_("Adjusting authentication database quota for folder %s to %d") % (folder, int(new_quota))) quota = int(new_quota) auth.set_user_attribute(primary_domain, user, _quota_attr, new_quota) if not int(current_quota) == int(quota): log.info(_("Correcting quota for %s to %s (currently %s)") % (folder, quota, current_quota)) self.imap._setquota(folder, quota) def set_user_mailhost(self, users=[], primary_domain=None, secondary_domain=[], folders=[]): if conf.has_option(primary_domain, 'mailserver_attribute'): _mailserver_attr = conf.get(primary_domain, 'mailserver_attribute') else: auth_mechanism = conf.get('kolab', 'auth_mechanism') _mailserver_attr = conf.get(auth_mechanism, 'mailserver_attribute') _inbox_folder_attr = conf.get('cyrus-sasl', 'result_attribute') if len(users) == 0: users = auth.list_users(primary_domain) for user in users: mailhost = None if type(user) == dict: if _mailserver_attr in user: if type(user[_mailserver_attr]) == list: _mailserver = user[_mailserver_attr].pop(0) elif type(user[_mailserver_attr]) == str: _mailserver = user[_mailserver_attr] else: _mailserver = auth.get_user_attribute(primary_domain, user, _mailserver_attr) if _inbox_folder_attr not in user: continue else: if type(user[_inbox_folder_attr]) == list: folder = "user/%s" % user[_inbox_folder_attr].pop(0) elif type(user[_inbox_folder_attr]) == str: folder = "user/%s" % user[_inbox_folder_attr] elif type(user) == str: _mailserver = auth.get_user_attribute(user, _mailserver_attr) folder = "user/%s" % (user) folder = folder.lower() _current_mailserver = self.imap.find_mailfolder_server(folder) if _mailserver is not None: # TODO: if not _current_mailserver == _mailserver: self.imap._xfer(folder, _current_mailserver, _mailserver) else: auth.set_user_attribute(primary_domain, user, _mailserver_attr, _current_mailserver) def parse_mailfolder(self, mailfolder): return self.imap.parse_mailfolder(mailfolder) def expunge_user_folders(self, inbox_folders=None): """ Delete folders that have no equivalent user qualifier in the list of users passed to this function, ... TODO: Explain the domain parameter, and actually get it to work properly. This also relates to threading for multi-domain deployments. Parameters: users A list of users. Can be a list of user qualifiers, e.g. [ 'user1', 'user2' ] or a list of user attribute dictionaries, e.g. [ { 'user1': { 'attr': 'value' } } ] primary_domain, secondary_domains """ if inbox_folders is None: inbox_folders = [] folders = self.list_user_folders() for folder in folders: log.debug(_("Checking folder: %s") % (folder), level=1) try: if inbox_folders.index(folder) > -1: continue else: log.info(_("Folder has no corresponding user (1): %s") % (folder)) self.delete_mailfolder("user/%s" % (folder)) except: log.info(_("Folder has no corresponding user (2): %s") % (folder)) try: self.delete_mailfolder("user/%s" % (folder)) except: pass def delete_mailfolder(self, mailfolder_path): """ Deletes a mail folder described by mailfolder_path. """ mbox_parts = self.parse_mailfolder(mailfolder_path) if mbox_parts is None: # We got user identifier only log.error(_("Please don't give us just a user identifier")) return log.info(_("Deleting folder %s") % (mailfolder_path)) self.imap.dm(self.folder_utf7(mailfolder_path)) def get_quota(self, mailfolder_path): try: return self.lq(self.folder_utf7(mailfolder_path)) except: return def get_quota_root(self, mailfolder_path): return self.lqr(self.folder_utf7(mailfolder_path)) def list_acls(self, folder): """ List the ACL entries on a folder """ return self.imap.lam(self.folder_utf7(folder)) def list_folders(self, pattern): return [self.folder_utf8(x) for x in self.lm(self.folder_utf7(pattern))] def list_user_folders(self, primary_domain=None, secondary_domains=[]): """ List the INBOX folders in the IMAP backend. Returns a list of unique base folder names. """ _folders = self.imap.lm("user/%") # TODO: Replace the .* below with a regex representing acceptable DNS # domain names. domain_re = ".*\.?%s$" acceptable_domain_name_res = [] if primary_domain is not None: for domain in [ primary_domain ] + secondary_domains: acceptable_domain_name_res.append(domain_re % (domain)) folders = [] for folder in _folders: folder_name = None if len(folder.split('@')) > 1: # TODO: acceptable domain name spaces #acceptable = False #for domain_name_re in acceptable_domain_name_res: #prog = re.compile(domain_name_re) #if prog.match(folder.split('@')[1]): #print "Acceptable indeed" #acceptable = True #if not acceptable: #print "%s is not acceptable against %s yet using %s" % (folder.split('@')[1], folder, domain_name_re) #if acceptable: #folder_name = "%s@%s" % (folder.split(self.separator)[1].split('@')[0], folder.split('@')[1]) folder_name = "%s@%s" % (folder.split(self.get_separator())[1].split('@')[0], folder.split('@')[1]) else: folder_name = "%s" % (folder.split(self.get_separator())[1]) if folder_name is not None: if not folder_name in folders: folders.append(folder_name) return folders def lm(self, *args, **kw): return self.imap.lm(*args, **kw) def lq(self, *args, **kw): return self.imap.lq(*args, **kw) def lqr(self, *args, **kw): try: return self.imap.lqr(*args, **kw) except: return (None, None, None) def undelete_mailfolder(self, *args, **kw): self.imap.undelete_mailfolder(*args, **kw) diff --git a/tests/unit/test-011-wallace_resources.py b/tests/unit/test-011-wallace_resources.py index 35d85a0..350974d 100644 --- a/tests/unit/test-011-wallace_resources.py +++ b/tests/unit/test-011-wallace_resources.py @@ -1,236 +1,296 @@ import pykolab import logging import datetime from pykolab import itip +from pykolab.imap import IMAP from icalendar import Calendar from email import message from email import message_from_string from wallace import module_resources from twisted.trial import unittest # define some iTip MIME messages itip_multipart = """MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="=_c8894dbdb8baeedacae836230e3436fd" From: "Doe, John" Date: Fri, 13 Jul 2012 13:54:14 +0100 Message-ID: <240fe7ae7e139129e9eb95213c1016d7@example.org> User-Agent: Roundcube Webmail/0.9-0.3.el6.kolab_3.0 To: resource-collection-car@example.org Subject: "test" has been updated --=_c8894dbdb8baeedacae836230e3436fd Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: quoted-printable *test* --=_c8894dbdb8baeedacae836230e3436fd Content-Type: text/calendar; charset=UTF-8; method=REQUEST; name=event.ics Content-Disposition: attachment; filename=event.ics Content-Transfer-Encoding: quoted-printable BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN CALSCALE:GREGORIAN METHOD:REQUEST BEGIN:VEVENT UID:626421779C777FBE9C9B85A80D04DDFA-A4BF5BBB9FEAA271 -DTSTAMP:20120713T1254140 +DTSTAMP:20120713T125414 DTSTART;TZID=3DEurope/London:20120713T100000 DTEND;TZID=3DEurope/London:20120713T110000 SUMMARY:test DESCRIPTION:test ORGANIZER;CN=3D"Doe, John":mailto:doe@example.org ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailt= o:resource-collection-car@example.org ATTENDEE;ROLE=3DOPT-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:anoth= er-resource@example.org TRANSP:OPAQUE END:VEVENT END:VCALENDAR --=_c8894dbdb8baeedacae836230e3436fd-- """ itip_non_multipart = """Return-Path: Sender: doe@example.org Content-Type: text/calendar; method=REQUEST; charset=UTF-8 Content-Transfer-Encoding: quoted-printable To: resource-collection-car@example.org From: doe@example.org Date: Mon, 24 Feb 2014 11:27:28 +0100 Message-ID: <1a3aa8995e83dd24cf9247e538ac913a@example.org> Subject: test BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN CALSCALE:GREGORIAN METHOD:REQUEST BEGIN:VEVENT UID:626421779C777FBE9C9B85A80D04DDFA-A4BF5BBB9FEAA271 -DTSTAMP:20120713T1254140 +DTSTAMP:20120713T125414 DTSTART;TZID=3DEurope/London:20120713T100000 DTEND;TZID=3DEurope/London:20120713T110000 SUMMARY:test DESCRIPTION:test ORGANIZER;CN=3D"Doe, John":mailto:doe@example.org ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DACCEPTED;RSVP=3DTRUE:mailt= o:resource-collection-car@example.org TRANSP:OPAQUE END:VEVENT END:VCALENDAR """ conf = pykolab.getConf() if not hasattr(conf, 'defaults'): conf.finalize_conf() class TestWallaceResources(unittest.TestCase): def setUp(self): # monkey-patch the pykolab.auth module to check API calls # without actually connecting to LDAP self.patch(pykolab.auth.Auth, "connect", self._mock_nop) self.patch(pykolab.auth.Auth, "disconnect", self._mock_nop) self.patch(pykolab.auth.Auth, "find_resource", self._mock_find_resource) self.patch(pykolab.auth.Auth, "get_entry_attributes", self._mock_get_entry_attributes) self.patch(pykolab.auth.Auth, "search_entry_by_attribute", self._mock_search_entry_by_attribute) + # Mock IMAP operations + self.patch(pykolab.imap.IMAP, "connect", self._mock_nop) + self.patch(pykolab.imap.IMAP, "disconnect", self._mock_nop) + self.patch(pykolab.imap.IMAP, "set_acl", self._mock_nop) + self.patch(pykolab.imap.IMAP, "append", self._mock_imap_append) + # intercept calls to smtplib.SMTP.sendmail() import smtplib self.patch(smtplib.SMTP, "__init__", self._mock_smtp_init) self.patch(smtplib.SMTP, "quit", self._mock_nop) self.patch(smtplib.SMTP, "connect", self._mock_smtp_init) self.patch(smtplib.SMTP, "sendmail", self._mock_smtp_sendmail) self.smtplog = [] + self.imap_append_log = [] - def _mock_nop(self, domain=None): + def _mock_nop(self, domain=None, arg3=None, arg4=None): pass def _mock_find_resource(self, address): if 'resource' not in address: return [] (prefix, domain) = address.split('@') entry_dn = "cn=" + prefix + ",ou=Resources,dc=" + ",dc=".join(domain.split('.')) return [entry_dn] def _mock_get_entry_attributes(self, domain, entry, attributes): (_, uid) = entry.split(',')[0].split('=') return {'cn': uid, 'mail': uid + "@example.org", '_attrib': attributes} def _mock_search_entry_by_attribute(self, attr, value, **kw): results = [] if value == "cn=Room 101,ou=Resources,dc=example,dc=org": results.append(('cn=Rooms,ou=Resources,dc=example,dc=org', {attr: value, 'owner': 'uid=doe,ou=People,dc=example,dc=org'})) return results def _mock_smtp_init(self, host=None, port=None, local_hostname=None, timeout=0): pass def _mock_smtp_sendmail(self, from_addr, to_addr, message, mail_options=None, rcpt_options=None): self.smtplog.append((from_addr, to_addr, message)) return [] + def _mock_imap_append(self, folder, msg): + self.imap_append_log.append((folder, msg)) + return True + def _get_ics_part(self, message): ics_part = None for part in message.walk(): if part.get_content_type() == 'text/calendar': ics_part = part return ics_part def _get_ical(self, ics): if hasattr(Calendar, 'from_ical'): cal = Calendar.from_ical(ics) elif hasattr(Calendar, 'from_string'): cal = Calendar.from_string(ics) for e in cal.walk(): if e.name == "VEVENT": return e return None def test_002_resource_record_from_email_address(self): res = module_resources.resource_record_from_email_address("doe@example.org") self.assertEqual(len(res), 0) def test_003_resource_records_from_itip_events(self): message = message_from_string(itip_multipart) itips = itip.events_from_message(message) res = module_resources.resource_records_from_itip_events(itips) self.assertEqual(len(res), 2, "Return resources: %r" % (res)) res = module_resources.resource_records_from_itip_events(itips, message['To']) self.assertEqual(len(res), 1, "Return target resource: %r" % (res)) self.assertEqual("cn=resource-collection-car,ou=Resources,dc=example,dc=org", res[0]) def test_004_get_resource_owner(self): owner1 = module_resources.get_resource_owner({'owner': "uid=foo,ou=People,cd=example,dc=org"}) self.assertIsInstance(owner1, dict) self.assertEqual("foo@example.org", owner1['mail']) self.assertIn("telephoneNumber", owner1['_attrib']) owner2 = module_resources.get_resource_owner({'owner': ["uid=john,ou=People,cd=example,dc=org", "uid=jane,ou=People,cd=example,dc=org"]}) self.assertIsInstance(owner2, dict) self.assertEqual("john@example.org", owner2['mail']) owner3 = module_resources.get_resource_owner({'dn': "cn=cars,ou=Resources,cd=example,dc=org"}) self.assertEqual(owner3, None) owner4 = module_resources.get_resource_owner({'dn': "cn=Room 101,ou=Resources,dc=example,dc=org"}) self.assertEqual("doe@example.org", owner4['mail']) def test_005_send_response_accept(self): itip_event = itip.events_from_message(message_from_string(itip_non_multipart)) module_resources.send_response("resource-collection-car@example.org", itip_event) self.assertEqual(len(self.smtplog), 1) self.assertEqual("resource-collection-car@example.org", self.smtplog[0][0]) self.assertEqual("doe@example.org", self.smtplog[0][1]) response = message_from_string(self.smtplog[0][2]) self.assertIn("ACCEPTED".lower(), response['subject'].lower(), "Participant status in message subject: %r" % (response['subject'])) self.assertTrue(response.is_multipart()) # find ics part of the response ics_part = self._get_ics_part(response) self.assertIsInstance(ics_part, message.Message) self.assertEqual(ics_part.get_param('method'), "REPLY") def test_006_send_response_delegate(self): # delegate resource-collection-car@example.org => resource-car-audi-a4@example.org itip_event = itip.events_from_message(message_from_string(itip_non_multipart))[0] itip_event['xml'].delegate('resource-collection-car@example.org', 'resource-car-audi-a4@example.org') itip_event['xml'].set_attendee_participant_status(itip_event['xml'].get_attendee('resource-car-audi-a4@example.org'), "ACCEPTED") module_resources.send_response("resource-collection-car@example.org", itip_event) self.assertEqual(len(self.smtplog), 2) self.assertEqual("resource-collection-car@example.org", self.smtplog[0][0]) self.assertEqual("resource-car-audi-a4@example.org", self.smtplog[1][0]) # delegated resource responds ACCEPTED response1 = message_from_string(self.smtplog[0][2]) ical1 = self._get_ical(self._get_ics_part(response1).get_payload(decode=True)) self.assertIn("DELEGATED".lower(), response1['subject'].lower(), "Participant status in message subject: %r" % (response1['subject'])) self.assertEqual(ical1['attendee'][1].__str__(), "MAILTO:resource-car-audi-a4@example.org") # resource collection responds DELEGATED response2 = message_from_string(self.smtplog[1][2]) ical2 = self._get_ical(self._get_ics_part(response2).get_payload(decode=True)) self.assertIn("ACCEPTED".lower(), response2['subject'].lower(), "Delegation message subject: %r" % (response2['subject'])) self.assertEqual(ical2['attendee'].__str__(), "MAILTO:resource-car-audi-a4@example.org") self.assertEqual(ical2['attendee'].params['PARTSTAT'], u"ACCEPTED") + + def test_007_accept_reservation_request_tentative_and_notify(self): + itip_event = itip.events_from_message(message_from_string(itip_multipart))[0] + + resource = { + 'mail': 'resource-collection-car@example.org', + 'kolabtargetfolder': 'shared/Resources/Test@example.org', + 'dn': 'cn=cars,ou=Resources,cd=example,dc=org', + 'cn': 'Cars', + 'owner': 'uid=foo,ou=People,cd=example,dc=org', + 'kolabinvitationpolicy': [module_resources.ACT_TENTATIVE_AND_NOTIFY] + } + + conf.command_set('wallace', 'webmail_url', 'https://%(domain)s/roundcube') + module_resources.imap = IMAP() + + module_resources.accept_reservation_request(itip_event, resource) + + self.assertEqual(len(self.smtplog), 2) + self.assertEqual(len(self.imap_append_log), 1) + + # Assert the reply message sent to the organizer + mail = message_from_string(self.smtplog[0][2]) + + self.assertEqual("resource-collection-car@example.org", self.smtplog[0][0]) + self.assertEqual("resource-collection-car@example.org", mail['from']) + self.assertEqual("doe@example.org", self.smtplog[0][1]) + self.assertEqual("doe@example.org", mail['to']) + self.assertTrue(mail.is_multipart()) + self.assertIn("Reservation Request for test was Tentatively Accepted", mail['subject']) + self.assertIn("PARTSTAT=TENTATIVE", self.smtplog[0][2]) + + # Assert the notification sent to the resource owner + mail = message_from_string(self.smtplog[1][2]) + + self.assertEqual("resource-collection-car@example.org", self.smtplog[1][0]) + self.assertEqual("resource-collection-car@example.org", mail['from']) + self.assertEqual("foo@example.org", self.smtplog[1][1]) + self.assertEqual("foo@example.org", mail['to']) + self.assertFalse(mail.is_multipart()) + self.assertIn("Booking for Cars has been Tentatively Accepted", mail['subject']) + body = mail.get_payload(decode=True) + self.assertIn("The resource booking for Cars by Doe, John ", body) + self.assertIn("You can change the status via https://example.org/roundcube?_task=calendar", body) + + # Assert the message appended to the resource folder + self.assertEqual(resource['kolabtargetfolder'], self.imap_append_log[0][0]) + self.assertIn("TENTATIVE", self.imap_append_log[0][1]) diff --git a/wallace/module_resources.py b/wallace/module_resources.py index 798577d..394ff64 100644 --- a/wallace/module_resources.py +++ b/wallace/module_resources.py @@ -1,1773 +1,1786 @@ # -*- coding: utf-8 -*- # pylint: disable=too-many-lines # 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 base64 import datetime from email import message_from_string from email.parser import Parser from email.utils import formataddr from email.utils import getaddresses import os import random import re import signal from six import string_types import time import uuid from dateutil.tz import tzlocal import modules import kolabformat import pykolab from pykolab.auth import Auth from pykolab.conf import Conf from pykolab.imap import IMAP from pykolab.logger import LoggerAdapter from pykolab.itip import events_from_message from pykolab.itip import check_event_conflict from pykolab.translate import _ 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 # define some contstants used in the code below COND_NOTIFY = 256 ACT_MANUAL = 1 ACT_ACCEPT = 2 ACT_REJECT = 8 +ACT_TENTATIVE = 16 ACT_ACCEPT_AND_NOTIFY = ACT_ACCEPT + COND_NOTIFY +ACT_TENTATIVE_AND_NOTIFY = ACT_TENTATIVE + COND_NOTIFY # noqa: E241 policy_name_map = { 'ACT_MANUAL': ACT_MANUAL, # noqa: E241 'ACT_ACCEPT': ACT_ACCEPT, # noqa: E241 'ACT_REJECT': ACT_REJECT, # noqa: E241 - 'ACT_ACCEPT_AND_NOTIFY': ACT_ACCEPT_AND_NOTIFY + 'ACT_ACCEPT_AND_NOTIFY': ACT_ACCEPT_AND_NOTIFY, + 'ACT_TENTATIVE_AND_NOTIFY': ACT_TENTATIVE_AND_NOTIFY } # pylint: disable=invalid-name log = pykolab.getLogger('pykolab.wallace/resources') extra_log_params = {'qid': '-'} log = LoggerAdapter(log, extra_log_params) 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, extra_log_params log.debug("cleanup(): %r, %r" % (auth, imap), level=8) extra_log_params['qid'] = '-' auth.disconnect() del auth # Disconnect IMAP or we lock the mailbox almost constantly imap.disconnect() del imap # pylint: disable=inconsistent-return-statements # pylint: disable=too-many-branches # pylint: disable=too-many-locals # pylint: disable=too-many-return-statements # pylint: disable=too-many-statements def execute(*args, **kw): global auth, imap, extra_log_params # TODO: Test for correct call. filepath = args[0] extra_log_params['qid'] = os.path.basename(filepath) # (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() if 'stage' in kw: 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']) # pylint: disable=broad-except except Exception as errmsg: log.error(_("Failed to parse iTip events 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.") else: any_itips = True log.debug( "iTip events attached to this message contain the following information: %r" % ( itip_events ), level=8 ) if any_itips: # See if any iTip actually allocates a resource. if (len([x['resources'] for x in itip_events if 'resources' in x]) > 0 or len([x['attendees'] for x in itip_events if 'attendees' in x]) > 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(r'.+\+[A-Za-z0-9=/-]+@', recipient): try: (prefix, host) = recipient.split('@') (local, uid) = prefix.split('+') reference_uid = base64.b64decode(uid, '-/') recipient = local + '@' + host # pylint: disable=broad-except except Exception: 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=8 ) # pylint: disable=broad-except except Exception as errmsg: log.error(_("Could not find envelope sender attendee: %r") % (errmsg)) 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=8 ) # pylint: disable=broad-except except Exception as errmsg: log.error(_("Could not find envelope attendee: %r") % (errmsg)) continue # ignore updates and cancellations to resource collections who already delegated the event att_delegated = (len(receiving_attendee.get_delegated_to()) > 0) att_nonpart = (receiving_attendee.get_role() == kolabformat.NonParticipant) att_rsvp = receiving_attendee.get_rsvp() if (att_delegated or att_nonpart) and not att_rsvp: 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: r_emails = [a.get_email() for a in itip_event['xml'].get_attendees()] _resource = resources[resource] if _resource['mail'] in r_emails and 'kolabtargetfolder' in _resource: (event, master) = find_existing_event( itip_event['uid'], itip_event['recurrence-id'], _resource ) if not event: continue # remove entire event if 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 else: 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: atts = [a.get_email() for a in itip_event['xml'].get_attendees()] if available_resource['mail'] in atts: # check if reservation was delegated if available_resource['mail'] != receiving_resource['mail']: if 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 'memberof' in available_resource: original_resource = resources[available_resource['memberof']] atts = [a.get_email() for a in itip_event['xml'].get_attendees()] if original_resource['mail'] in atts: # # 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=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 'kolabtargetfolder' in resource_attrs: try: expunge_resource_calendar(resource_attrs['kolabtargetfolder']) # pylint: disable=broad-except except Exception as errmsg: log.error( _("Expunge resource calendar for %s (%s) failed: %r") % ( resource_dn, resource_attrs['kolabtargetfolder'], errmsg ) ) 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=8 ) typ, data = imap.imap.m.fetch(num, '(RFC822)') try: event = event_from_message(message_from_string(data[0][1])) # pylint: disable=broad-except except Exception as errmsg: log.error(_("Failed to parse event from message %s/%s: %r") % (mailbox, num, errmsg)) 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 'kolabtargetfolder' not in resources[resource]: continue # sets the 'conflicting' flag and adds a list of conflicting events found try: num_messages += read_resource_calendar(resources[resource], itip_events) # pylint: disable=broad-except except Exception as 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=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=8) if resource not in resources: log.debug(_("Resource %r has been popped from the list") % (resource), level=8) continue if 'conflicting_events' not in resources[resource]: 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=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=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 _eas = [a.get_email() for a in itip_event['xml'].get_attendees()] # Now we have the event that was conflicting if resources[resource]['mail'] in _eas: # 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 _eas = [a.get_email() for a in itip_event['xml'].get_attendees()] if resources[resource]['mail'] in _eas: 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 'memberof' in resources[resource]: original_resource = resources[resources[resource]['memberof']] # Randomly select a target resource from the resource collection. _selected = random.randint(0, (len(original_resource['uniquemember']) - 1)) available_resource = resources[original_resource['uniquemember'][_selected]] 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=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=8 ) typ, data = imap.imap.m.fetch(num, '(UID RFC822)') try: msguid = re.search(r"\WUID (\d+)", data[0][0]).group(1) # pylint: disable=broad-except except Exception: log.error(_("No UID found in IMAP response: %r") % (data[0][0])) continue try: event = event_from_message(message_from_string(data[0][1])) # pylint: disable=broad-except except Exception as 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=8) try: imap.imap.m.select(imap.folder_quote(mailbox)) typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid)) # pylint: disable=broad-except except Exception as errmsg: log.error(_("Failed to access resource calendar:: %r") % (errmsg)) 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) # pylint: disable=broad-except except Exception: 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: if 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) # pylint: disable=broad-except except Exception as errmsg: log.error(_("Failed to parse event from message %s/%s: %r") % (mailbox, num, errmsg)) 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. + ACCEPTED/TENTATIVE and sends an iTip reply message to the organizer. """ owner = get_resource_owner(resource) confirmation_required = False + partstat = 'ACCEPTED' if not confirmed and owner: if invitationpolicy is None: invitationpolicy = get_resource_invitationpolicy(resource) 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 + partstat = 'TENTATIVE' break - - partstat = 'TENTATIVE' if confirmation_required else 'ACCEPTED' + if policy & ACT_TENTATIVE: + partstat = 'TENTATIVE' 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: + # Only send a response back to the organizer if this is a confirmation from the resource owner. + if saved and confirmed: 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 'existing_events' in resource 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, + resource['kolabtargetfolder'], conf.get(conf.get('kolab', 'imap_backend'), 'admin_login'), "lrswipkxtecda" ) # append new version - result = imap.imap.m.append( - targetfolder, - None, - None, + result = imap.append( + resource['kolabtargetfolder'], save_event.to_message(creator="Kolab Server ").as_string() ) + return result # pylint: disable=broad-except except Exception as 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=8) for num in data[0].split(): imap.imap.m.store(num, '+FLAGS', '\\Deleted') imap.imap.m.expunge() return True # pylint: disable=broad-except except Exception as 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 local_domains is not 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=8) elif isinstance(resource_records, string_types): 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=8) attendees_raw = [] _lars = [ x for x in [ y['attendees'] for y in itip_events if 'attendees' in y and isinstance(y['attendees'], list) ] ] for list_attendees_raw in _lars: attendees_raw.extend(list_attendees_raw) _lars = [ y['attendees'] for y in itip_events if 'attendees' in y and isinstance(y['attendees'], string_types) ] for list_attendees_raw in _lars: attendees_raw.append(list_attendees_raw) 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 = [] _lrrs = [x for x in [y['resources'] for y in itip_events if 'resource' in y]] for list_resources_raw in _lrrs: resources_raw.extend(list_resources_raw) log.debug(_("Raw set of resources: %r") % (resources_raw), level=8) # consider organizer (in REPLY messages), too organizers_raw = [ re.sub(r'\+[A-Za-z0-9=/-]+@', '@', str(y['organizer'])) for y in itip_events if 'organizer' in y ] 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=8) elif isinstance(_resource_records, string_types): 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, string_types): 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 'kolabsharedfolder' not in [x.lower() for x in resource_attrs['objectclass']]: if 'uniquemember' in resource_attrs: 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 'owner' not in member_attrs and 'owner' in resources[resource_dn]: 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 'kolabinvitationpolicy' in attrs: if not isinstance(attrs['kolabinvitationpolicy'], list): attrs['kolabinvitationpolicy'] = [attrs['kolabinvitationpolicy']] attrs['kolabinvitationpolicy'] = [ policy_name_map[p] for p in attrs['kolabinvitationpolicy'] if p in policy_name_map ] elif isinstance(parent, dict) and 'kolabinvitationpolicy' in parent: attrs['kolabinvitationpolicy'] = parent['kolabinvitationpolicy'] def get_resource_collection(email_address): """ Obtain a resource collection object from an 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 'kolabsharedfolder' not 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 'owner' in resource: 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 'owner' in collection and isinstance(collection['owner'], list): owners += collection['owner'] elif 'owner' in collection: 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 'kolabinvitationpolicy' not in resource 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=8 ) for dn, collection in collections: # ldap.search_entry_by_attribute() doesn't return the attributes lower-cased if 'kolabInvitationPolicy' in collection: collection['kolabinvitationpolicy'] = collection['kolabInvitationPolicy'] if 'kolabinvitationpolicy' in collection: parse_kolabinvitationpolicy(collection) resource['kolabinvitationpolicy'] = collection['kolabinvitationpolicy'] break return resource['kolabinvitationpolicy'] if 'kolabinvitationpolicy' in resource 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 'telephoneNumber' in owner else '' ) return message_text def send_owner_notification(resource, owner, itip_event, success=True): """ Send a reservation notification to the resource owner """ 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 'preferredlanguage' in owner: 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') )) seed = random.randint(0, 6) alarm_after = (seed * 10) + 60 log.debug(_("Set alarm to %s seconds") % (alarm_after), level=8) signal.alarm(alarm_after) result = modules._sendmail(resource['mail'], owner['mail'], msg.as_string()) log.debug(_("Owner notification was sent successfully: %r") % result, level=8) signal.alarm(0) def owner_notification_text(resource, owner, event, success): organizer = event.get_organizer() status = event.get_attendee_by_email(resource['mail']).get_participant_status(True) + domain = resource['mail'].split('@')[1] + url = conf.get('wallace', 'webmail_url') 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. *** """ ) + + if url: + message_text += ( + "\n " + + _("You can change the status via %(url)s") % { 'url': url } + '?_task=calendar' + ) 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 for the event: %(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() + 'orgemail': organizer.email(), + 'domain': domain } 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 )