diff --git a/docker/kolab/utils/23-patch-system.sh b/docker/kolab/utils/23-patch-system.sh index fce29788..19eeb4e7 100755 --- a/docker/kolab/utils/23-patch-system.sh +++ b/docker/kolab/utils/23-patch-system.sh @@ -1,10 +1,11 @@ #!/bin/bash PATCHPATH=$(pwd)/patches -# Example for applying a pykolab patch -#pushd /usr/lib/python2.7/site-packages/ || exit -#patch -p1 < "$PATCHPATH/0001-Resolve-base_dn-in-kolab_user_base_dn-user_base_dn-a.patch" -#popd || exit -#systemctl restart kolabd -#systemctl restart wallace +pushd /usr/lib/python2.7/site-packages/ || exit +patch -p1 < "$PATCHPATH/0001-Resolve-base_dn-in-kolab_user_base_dn-user_base_dn-a.patch" +patch -p1 < "$PATCHPATH/0001-Make-iTip-messages-outlook-compatible.patch" +patch -p1 < "$PATCHPATH/0002-Implement-ACT_STORE_AND_NOTIFY-policy-for-resources-.patch" +popd || exit +systemctl restart kolabd +systemctl restart wallace diff --git a/docker/kolab/utils/patches/0001-Make-iTip-messages-outlook-compatible.patch b/docker/kolab/utils/patches/0001-Make-iTip-messages-outlook-compatible.patch new file mode 100644 index 00000000..943eb962 --- /dev/null +++ b/docker/kolab/utils/patches/0001-Make-iTip-messages-outlook-compatible.patch @@ -0,0 +1,36 @@ +From 294ef5b076c42d029fdb58b8167124f0cfd9e4a8 Mon Sep 17 00:00:00 2001 +From: Christian Mollekopf +Date: Fri, 19 Nov 2021 11:16:33 +0100 +Subject: [PATCH 1/2] Make iTip messages outlook compatible + +Summary: Use multipart/alternative and loose the Content-Disposition header + +Differential Revision: https://git.kolab.org/D3056 +--- + pykolab/xml/event.py | 3 +-- + 1 file changed, 1 insertion(+), 2 deletions(-) + +diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py +index 276bb6f..a668f77 100644 +--- a/pykolab/xml/event.py ++++ b/pykolab/xml/event.py +@@ -1273,7 +1273,7 @@ class Event(object): + from email import charset + charset.add_charset('utf-8', charset.SHORTEST, charset.QP) + +- msg = MIMEMultipart() ++ msg = MIMEMultipart("alternative") + + msg_from = None + attendees = None +@@ -1353,7 +1353,6 @@ class Event(object): + + part.set_payload(self.as_string_itip(method=method)) + +- part.add_header('Content-Disposition', 'attachment; filename="event.ics"') + part.add_header('Content-Transfer-Encoding', '8bit') + + msg.attach(part) +-- +2.33.1 + diff --git a/docker/kolab/utils/patches/0001-Resolve-base_dn-in-kolab_user_base_dn-user_base_dn-a.patch b/docker/kolab/utils/patches/0001-Resolve-base_dn-in-kolab_user_base_dn-user_base_dn-a.patch new file mode 100644 index 00000000..eecce30b --- /dev/null +++ b/docker/kolab/utils/patches/0001-Resolve-base_dn-in-kolab_user_base_dn-user_base_dn-a.patch @@ -0,0 +1,92 @@ +From f0a02b4484360617baa434bada6c651b8b0b5d30 Mon Sep 17 00:00:00 2001 +From: Aleksander Machniak +Date: Fri, 1 Oct 2021 15:08:12 +0200 +Subject: [PATCH 1/2] Resolve %base_dn in kolab_user_base_dn, user_base_dn and + resource_base_dn + +Reviewers: #pykolab_developers, vanmeeuwen + +Reviewed By: #pykolab_developers, vanmeeuwen + +Subscribers: #pykolab_developers + +Differential Revision: https://git.kolab.org/D2900 +--- + pykolab/auth/ldap/__init__.py | 40 ++++++++++++++++++++--------------- + 1 file changed, 23 insertions(+), 17 deletions(-) + +diff --git a/pykolab/auth/ldap/__init__.py b/pykolab/auth/ldap/__init__.py +index 5c8c668..046c30c 100644 +--- a/pykolab/auth/ldap/__init__.py ++++ b/pykolab/auth/ldap/__init__.py +@@ -647,13 +647,7 @@ class LDAP(Base): + 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 ldap_base_dn is not None and not ldap_base_dn == config_base_dn: +- resource_base_dn = ldap_base_dn +- else: +- resource_base_dn = config_base_dn ++ resource_base_dn = self._object_base_dn('resource') + + _results = self.ldap.search_s( + resource_base_dn, +@@ -801,13 +795,7 @@ class LDAP(Base): + 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 ldap_base_dn is not None and not ldap_base_dn == config_base_dn: +- resource_base_dn = ldap_base_dn +- else: +- resource_base_dn = config_base_dn ++ resource_base_dn = self._object_base_dn('resource') + + _results = self.ldap.search_s( + resource_base_dn, +@@ -2470,9 +2458,7 @@ class LDAP(Base): + + conf_prefix = 'kolab_' if kolabuser else '' + +- user_base_dn = self.config_get(conf_prefix + 'user_base_dn') +- if user_base_dn is None: +- user_base_dn = self.config_get('base_dn') ++ user_base_dn = self._object_base_dn('user', conf_prefix) + + auth_attrs = self.config_get_list('auth_attributes') + +@@ -2684,6 +2670,26 @@ class LDAP(Base): + + return domains + ++ def _object_base_dn(self, objectType, prefix=''): ++ """ ++ Get configured base DN for specified Kolab object type ++ """ ++ object_base_dn = self.config_get(prefix + objectType + '_base_dn') ++ config_base_dn = self.config_get('base_dn') ++ ldap_base_dn = self._kolab_domain_root_dn(self.domain) ++ ++ if ldap_base_dn is not None and not ldap_base_dn == config_base_dn: ++ base_dn = ldap_base_dn ++ else: ++ base_dn = config_base_dn ++ ++ if object_base_dn is None: ++ object_base_dn = base_dn ++ else: ++ object_base_dn = object_base_dn % ({'base_dn': base_dn}) ++ ++ return object_base_dn ++ + def _synchronize_callback(self, *args, **kw): + """ + Determine the characteristics of the callback being placed, and +-- +2.33.1 + diff --git a/docker/kolab/utils/patches/0002-Implement-ACT_STORE_AND_NOTIFY-policy-for-resources-.patch b/docker/kolab/utils/patches/0002-Implement-ACT_STORE_AND_NOTIFY-policy-for-resources-.patch new file mode 100644 index 00000000..fb3b55b1 --- /dev/null +++ b/docker/kolab/utils/patches/0002-Implement-ACT_STORE_AND_NOTIFY-policy-for-resources-.patch @@ -0,0 +1,324 @@ +From 51d63556196de7866e9582fde55310ac2f3eee47 Mon Sep 17 00:00:00 2001 +From: Aleksander Machniak +Date: Sun, 28 Nov 2021 20:22:40 +0100 +Subject: [PATCH 2/2] Implement ACT_STORE_AND_NOTIFY policy for resources, add + webmail url to the notification body + +Set the status to NEEDS_ACTION and don't send out an immediate reply to +the organizer. + +Test Plan: nosetests + +Reviewers: #pykolab_developers + +Subscribers: #pykolab_developers + +Differential Revision: https://git.kolab.org/D3050 +--- + pykolab/imap/__init__.py | 3 + + tests/unit/test-011-wallace_resources.py | 56 +++++++++++++++- + wallace/module_resources.py | 82 ++++++++++++++++-------- + 3 files changed, 113 insertions(+), 28 deletions(-) + +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 +@@ -285,6 +285,9 @@ class IMAP(object): + 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) +diff --git a/tests/unit/test-011-wallace_resources.py b/tests/unit/test-011-wallace_resources.py +index 35d85a0..8b8cc0f 100644 +--- a/tests/unit/test-011-wallace_resources.py ++++ b/tests/unit/test-011-wallace_resources.py +@@ -3,6 +3,7 @@ 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 +@@ -41,7 +42,7 @@ 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 +@@ -75,7 +76,7 @@ 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 +@@ -105,6 +106,12 @@ class TestWallaceResources(unittest.TestCase): + 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) +@@ -113,8 +120,9 @@ class TestWallaceResources(unittest.TestCase): + 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): +@@ -142,6 +150,10 @@ class TestWallaceResources(unittest.TestCase): + 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(): +@@ -234,3 +246,41 @@ class TestWallaceResources(unittest.TestCase): + 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_store_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_STORE_AND_NOTIFY] ++ } ++ ++ conf.command_set('wallace', 'webmail_url', 'https://%(domain)s/roundcube') ++ module_resources.imap = IMAP() ++ ++ module_resources.accept_reservation_request(itip_event, resource) ++ ++ # Assert no reply message sent to the organizer ++ self.assertEqual(len(self.smtplog), 1) ++ self.assertEqual(len(self.imap_append_log), 1) ++ ++ # Assert the notification sent to the resource owner ++ mail = message_from_string(self.smtplog[0][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("New booking request for Cars", mail['subject']) ++ body = mail.get_payload(decode=True) ++ self.assertIn("The resource booking request is 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("NEEDS-ACTION", self.imap_append_log[0][1]) +diff --git a/wallace/module_resources.py b/wallace/module_resources.py +index 798577d..44b1db5 100644 +--- a/wallace/module_resources.py ++++ b/wallace/module_resources.py +@@ -58,14 +58,17 @@ COND_NOTIFY = 256 + ACT_MANUAL = 1 + ACT_ACCEPT = 2 + ACT_REJECT = 8 ++ACT_STORE = 16 + ACT_ACCEPT_AND_NOTIFY = ACT_ACCEPT + COND_NOTIFY ++ACT_STORE_AND_NOTIFY = ACT_STORE + 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_STORE_AND_NOTIFY': ACT_STORE_AND_NOTIFY + } + + # pylint: disable=invalid-name +@@ -1031,11 +1034,14 @@ def accept_reservation_request( + ): + """ + 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. ++ calendar. Then, depending on the policy, set the attendee status of the given resource to ++ ACCEPTED/TENTATIVE and send an iTip reply message to the organizer, or set the status to ++ NEEDS-ACTION and don't send a reply to the organizer. + """ + owner = get_resource_owner(resource) + confirmation_required = False ++ do_send_response = True ++ partstat = 'ACCEPTED' + + if not confirmed and owner: + if invitationpolicy is None: +@@ -1046,9 +1052,13 @@ def accept_reservation_request( + for policy in invitationpolicy: + if policy & ACT_MANUAL and owner['mail']: + confirmation_required = True ++ partstat = 'TENTATIVE' ++ break ++ if policy & ACT_STORE: ++ partstat = 'NEEDS-ACTION' ++ # Do not send an immediate response to the organizer ++ do_send_response = False + break +- +- partstat = 'TENTATIVE' if confirmation_required else 'ACCEPTED' + + itip_event['xml'].set_transparency(False) + itip_event['xml'].set_attendee_participant_status( +@@ -1063,7 +1073,7 @@ def accept_reservation_request( + level=8 + ) + +- if saved: ++ if saved and do_send_response: + send_response(delegator['mail'] if delegator else resource['mail'], itip_event, owner) + + if owner and confirmation_required: +@@ -1110,7 +1120,6 @@ def save_resource_event(itip_event, resource): + """ + 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: +@@ -1132,18 +1141,17 @@ def save_resource_event(itip_event, resource): + + 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 +@@ -1642,16 +1650,21 @@ def send_owner_notification(resource, owner, itip_event, success=True): + if 'preferredlanguage' in owner: + pykolab.translate.setUserLanguage(owner['preferredlanguage']) + +- message_text = owner_notification_text(resource, owner, itip_event['xml'], success) ++ message_text = owner_notification_text(resource, owner, itip_event['xml'], success, status) + + 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') +- )) ++ if status == 'NEEDS-ACTION': ++ msg['Subject'] = utils.str2unicode(_('New booking request for %s') % ( ++ resource['cn'] ++ )) ++ else: ++ 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 +@@ -1663,19 +1676,37 @@ def send_owner_notification(resource, owner, itip_event, success=True): + signal.alarm(0) + + +-def owner_notification_text(resource, owner, event, success): ++def owner_notification_text(resource, owner, event, success, status): + 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. ++ if status == 'NEEDS-ACTION': ++ message_text = _( ++ """ ++ The resource booking request is for %(resource)s by %(orgname)s <%(orgemail)s> for %(date)s. + +- *** This is an automated message, sent to you as the resource owner. *** +- """ +- ) ++ *** This is an automated message, sent to you as the resource owner. *** ++ """ ++ ) ++ else: ++ 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 = _( + """ +@@ -1695,7 +1726,8 @@ def owner_notification_text(resource, owner, event, success): + 'date': event.get_date_text(), + 'status': participant_status_label(status), + 'orgname': organizer.name(), +- 'orgemail': organizer.email() ++ 'orgemail': organizer.email(), ++ 'domain': domain + } + + +-- +2.33.1 +