diff options
author | Thomas Bruederli <bruederli@kolabsys.com> | 2014-07-07 02:14:32 (GMT) |
---|---|---|
committer | Thomas Bruederli <bruederli@kolabsys.com> | 2014-07-07 02:14:32 (GMT) |
commit | ce4be6aec8a5112ead076f7a2c6a8ad7eeb403e6 (patch) | |
tree | cd72c086da6cc59160cf9fffe3a18f596e486e5c | |
parent | 223871e43e7ff6cd3c4dcd49e5c362a1fdf912df (diff) | |
download | pykolab-ce4be6aec8a5112ead076f7a2c6a8ad7eeb403e6.tar.gz |
Start implementing a new wallace module 'invitationpolicy' to automatically process iTip messages according to per-user policies
-rw-r--r-- | conf/kolab.conf | 5 | ||||
-rw-r--r-- | tests/functional/test_wallace/test_007_invitationpolicy.py | 449 | ||||
-rw-r--r-- | tests/functional/user_add.py | 4 | ||||
-rw-r--r-- | tests/unit/test-012-wallace_invitationpolicy.py | 129 | ||||
-rw-r--r-- | wallace/module_invitationpolicy.py | 721 |
5 files changed, 1306 insertions, 2 deletions
diff --git a/conf/kolab.conf b/conf/kolab.conf index 2f8ea2b..cb3a7ba 100644 --- a/conf/kolab.conf +++ b/conf/kolab.conf @@ -361,10 +361,13 @@ admin_password = Welcome123 result_attribute = mail [wallace] -modules = resources, footer +modules = resources, invitationpolicy, footer footer_text = /etc/kolab/footer.text footer_html = /etc/kolab/footer.html +; default settings for kolabInvitationPolicy +kolab_invitation_policy = ACT_ACCEPT_IF_NO_CONFLICT:example.org, ACT_MANUAL + ; This is a domain name space specific section, that enables us to override ; all settings, for example, the LDAP URI, base and bind DNs, scopes, filters, ; etc. Note that overriding the LDAP settings for the primary domain name space diff --git a/tests/functional/test_wallace/test_007_invitationpolicy.py b/tests/functional/test_wallace/test_007_invitationpolicy.py new file mode 100644 index 0000000..0490ec1 --- /dev/null +++ b/tests/functional/test_wallace/test_007_invitationpolicy.py @@ -0,0 +1,449 @@ +import time +import pykolab +import smtplib +import email +import datetime +import pytz +import uuid +import kolabformat + +from pykolab.imap import IMAP +from wallace import module_resources + +from email import message_from_string +from twisted.trial import unittest + +import tests.functional.resource_func as funcs + +conf = pykolab.getConf() + +itip_invitation = """ +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:%(uid)s +DTSTAMP:20140213T1254140 +DTSTART;TZID=Europe/Berlin:%(start)s +DTEND;TZID=Europe/Berlin:%(end)s +SUMMARY:%(summary)s +DESCRIPTION:test +ORGANIZER;CN="Doe, John":mailto:john.doe@example.org +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=%(partstat)s;RSVP=TRUE:mailto:%(mailto)s +ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=TENTATIVE;RSVP=FALSE:mailto:somebody@else.com +TRANSP:OPAQUE +SEQUENCE:%(sequence)d +END:VEVENT +END:VCALENDAR +""" + +itip_cancellation = """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN +CALSCALE:GREGORIAN +METHOD:CANCEL +BEGIN:VEVENT +UID:%(uid)s +DTSTAMP:20140218T1254140 +DTSTART;TZID=Europe/Berlin:20120713T100000 +DTEND;TZID=Europe/Berlin:20120713T110000 +SUMMARY:%(summary)s +DESCRIPTION:test +ORGANIZER;CN="Doe, John":mailto:john.doe@example.org +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:%(mailto)s +TRANSP:OPAQUE +SEQUENCE:%(sequence)d +END:VEVENT +END:VCALENDAR +""" + +itip_recurring = """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.9.2//EN +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VEVENT +UID:%(uid)s +DTSTAMP:20140213T1254140 +DTSTART;TZID=Europe/Zurich:%(start)s +DTEND;TZID=Europe/Zurich:%(end)s +RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=10 +SUMMARY:%(summary)s +DESCRIPTION:test +ORGANIZER;CN="Doe, John":mailto:john.doe@example.org +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=%(partstat)s;RSVP=TRUE:mailto:%(mailto)s +TRANSP:OPAQUE +SEQUENCE:%(sequence)d +END:VEVENT +END:VCALENDAR +""" + +itip_reply = """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//pykolab-0.6.9-1//kolab.org// +CALSCALE:GREGORIAN +METHOD:REPLY +BEGIN:VEVENT +SUMMARY:%(summary)s +UID:%(uid)s +DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:%(start)s +DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:%(end)s +DTSTAMP;VALUE=DATE-TIME:20140706T171038Z +ORGANIZER;CN="Doe, John":MAILTO:%(organizer)s +ATTENDEE;CUTYPE=INDIVIDUAL;PARTSTAT=%(partstat)s;ROLE=REQ-PARTICIPANT:mailto:%(mailto)s +PRIORITY:0 +SEQUENCE:%(sequence)d +END:VEVENT +END:VCALENDAR +""" + +mime_message = """MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="=_c8894dbdb8baeedacae836230e3436fd" +From: "Doe, John" <john.doe@example.org> +Date: Tue, 25 Feb 2014 13:54:14 +0100 +Message-ID: <240fe7ae7e139129e9eb95213c1016d7@example.org> +To: %s +Subject: "test" + +--=_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=%s; name=event.ics +Content-Disposition: attachment; filename=event.ics +Content-Transfer-Encoding: 8bit + +%s +--=_c8894dbdb8baeedacae836230e3436fd-- +""" + +class TestWallaceInvitationpolicy(unittest.TestCase): + + john = None + + @classmethod + def setUp(self): + """ Compatibility for twisted.trial.unittest + """ + if not self.john: + self.setup_class() + + @classmethod + def setup_class(self, *args, **kw): + from tests.functional.purge_users import purge_users + purge_users() + + self.john = { + 'displayname': 'John Doe', + 'mail': 'john.doe@example.org', + 'sender': 'John Doe <john.doe@example.org>', + 'dn': 'uid=doe,ou=People,dc=example,dc=org', + 'mailbox': 'user/john.doe@example.org', + 'kolabtargetfolder': 'user/john.doe/Calendar@example.org', + 'kolabinvitationpolicy': ['ACT_UPDATE', 'ACT_MANUAL'] + } + + self.jane = { + 'displayname': 'Jane Manager', + 'mail': 'jane.manager@example.org', + 'sender': 'Jane Manager <jane.manager@example.org>', + 'dn': 'uid=manager,ou=People,dc=example,dc=org', + 'mailbox': 'user/jane.manager@example.org', + 'kolabtargetfolder': 'user/jane.manager/Calendar@example.org', + 'kolabinvitationpolicy': ['ACT_ACCEPT_IF_NO_CONFLICT','ACT_REJECT_IF_CONFLICT'] + } + + from tests.functional.user_add import user_add + user_add("John", "Doe", kolabinvitationpolicy=self.john['kolabinvitationpolicy']) + user_add("Jane", "Manager", kolabinvitationpolicy=self.jane['kolabinvitationpolicy']) + + time.sleep(1) + from tests.functional.synchronize import synchronize_once + synchronize_once() + + def send_message(self, itip_payload, to_addr, from_addr=None, method="REQUEST"): + if from_addr is None: + from_addr = self.john['mail'] + + smtp = smtplib.SMTP('localhost', 10026) + smtp.sendmail(from_addr, to_addr, mime_message % (to_addr, method, itip_payload)) + + def send_itip_invitation(self, attendee_email, start=None, allday=False, template=None, summary="test", sequence=0, partstat='NEEDS-ACTION'): + if start is None: + start = datetime.datetime.now() + + uid = str(uuid.uuid4()) + + if allday: + default_template = itip_allday + end = start + datetime.timedelta(days=1) + date_format = '%Y%m%d' + else: + end = start + datetime.timedelta(hours=4) + default_template = itip_invitation + date_format = '%Y%m%dT%H%M%S' + + self.send_message((template if template is not None else default_template) % { + 'uid': uid, + 'start': start.strftime(date_format), + 'end': end.strftime(date_format), + 'mailto': attendee_email, + 'summary': summary, + 'sequence': sequence, + 'partstat': partstat + }, + attendee_email) + + return uid + + def send_itip_update(self, attendee_email, uid, start=None, template=None, summary="test", sequence=1, partstat='ACCEPTED'): + if start is None: + start = datetime.datetime.now() + + end = start + datetime.timedelta(hours=4) + self.send_message((template if template is not None else itip_invitation) % { + 'uid': uid, + 'start': start.strftime('%Y%m%dT%H%M%S'), + 'end': end.strftime('%Y%m%dT%H%M%S'), + 'mailto': attendee_email, + 'summary': summary, + 'sequence': sequence, + 'partstat': partstat + }, + attendee_email) + + return uid + + def send_itip_reply(self, uid, mailto, attendee_email, start=None, template=None, summary="test", sequence=1, partstat='ACCEPTED'): + if start is None: + start = datetime.datetime.now() + + end = start + datetime.timedelta(hours=4) + self.send_message((template if template is not None else itip_reply) % { + 'uid': uid, + 'start': start.strftime('%Y%m%dT%H%M%S'), + 'end': end.strftime('%Y%m%dT%H%M%S'), + 'mailto': attendee_email, + 'organizer': mailto, + 'summary': summary, + 'sequence': sequence, + 'partstat': partstat + }, + mailto, + attendee_email, + method="REPLY") + + return uid + + def send_itip_cancel(self, resource_email, uid): + self.send_message(itip_cancellation % ( + uid, + resource_email + ), + resource_email) + + return uid + + def create_calendar_event(self, start=None, summary="test", sequence=0, user=None, attendee=None): + if start is None: + start = datetime.datetime.now(pytz.timezone("Europe/Berlin")) + if user is None: + user = self.john + if attendee is None: + attendee = self.jane + + end = start + datetime.timedelta(hours=4) + + event = pykolab.xml.Event() + event.set_start(start) + event.set_end(end) + event.set_organizer(user['mail'], user['displayname']) + event.add_attendee(attendee['mail'], attendee['displayname'], role="REQ-PARTICIPANT", participant_status="NEEDS-ACTION", rsvp=True) + event.set_summary(summary) + event.set_sequence(sequence) + + imap = IMAP() + imap.connect() + + mailbox = imap.folder_quote(user['kolabtargetfolder']) + imap.set_acl(mailbox, "cyrus-admin", "lrswipkxtecda") + imap.imap.m.select(mailbox) + + result = imap.imap.m.append( + mailbox, + None, + None, + event.to_message().as_string() + ) + + return event.get_uid() + + def check_message_received(self, subject, from_addr=None, mailbox=None): + if mailbox is None: + mailbox = self.john['mailbox'] + + imap = IMAP() + imap.connect() + + mailbox = imap.folder_quote(mailbox) + imap.set_acl(mailbox, "cyrus-admin", "lrs") + imap.imap.m.select(mailbox) + + found = None + retries = 15 + + while not found and retries > 0: + retries -= 1 + + typ, data = imap.imap.m.search(None, '(UNDELETED HEADER FROM "%s")' % (from_addr) if from_addr else 'UNDELETED') + for num in data[0].split(): + typ, msg = imap.imap.m.fetch(num, '(RFC822)') + message = message_from_string(msg[0][1]) + if message['Subject'] == subject: + found = message + break + + time.sleep(1) + + imap.disconnect() + + return found + + def check_user_calendar_event(self, mailbox, uid=None): + imap = IMAP() + imap.connect() + + mailbox = imap.folder_quote(mailbox) + imap.set_acl(mailbox, "cyrus-admin", "lrs") + imap.imap.m.select(mailbox) + + found = None + retries = 15 + + while not found and retries > 0: + retries -= 1 + + typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid) if uid else '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")') + for num in data[0].split(): + typ, data = imap.imap.m.fetch(num, '(RFC822)') + event_message = message_from_string(data[0][1]) + + # return matching UID or first event found + if uid and event_message['subject'] != uid: + continue + + for part in event_message.walk(): + if part.get_content_type() == "application/calendar+xml": + payload = part.get_payload(decode=True) + found = pykolab.xml.event_from_string(payload) + break + + if found: + break + + time.sleep(1) + + return found + + def purge_mailbox(self, mailbox): + imap = IMAP() + imap.connect() + mailbox = imap.folder_quote(mailbox) + imap.set_acl(mailbox, "cyrus-admin", "lrwcdest") + imap.imap.m.select(mailbox) + + typ, data = imap.imap.m.search(None, 'ALL') + for num in data[0].split(): + imap.imap.m.store(num, '+FLAGS', '\\Deleted') + + imap.imap.m.expunge() + imap.disconnect() + + + def test_001_invite_user(self): + start = datetime.datetime(2014,8,13, 10,0,0) + uid = self.send_itip_invitation(self.jane['mail'], start) + + response = self.check_message_received('"test" has been ACCEPTED', self.jane['mail']) + self.assertIsInstance(response, email.message.Message) + + event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid) + self.assertIsInstance(event, pykolab.xml.Event) + self.assertEqual(event.get_summary(), "test") + + # send update with the same sequence: no re-scheduling + self.send_itip_update(self.jane['mail'], uid, start, summary="test updated", sequence=0, partstat='ACCEPTED') + + time.sleep(10) + event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid) + self.assertIsInstance(event, pykolab.xml.Event) + self.assertEqual(event.get_summary(), "test updated") + self.assertEqual(event.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted) + + + # @depends on test_001_invite_user + def test_002_invite_conflict(self): + uid = self.send_itip_invitation(self.jane['mail'], datetime.datetime(2014,8,13, 11,0,0), summary="test2") + + response = self.check_message_received('"test2" has been DECLINED', self.jane['mail']) + self.assertIsInstance(response, email.message.Message) + + event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid) + self.assertIsInstance(event, pykolab.xml.Event) + self.assertEqual(event.get_summary(), "test2") + + + def test_003_invite_rescheduling(self): + start = datetime.datetime(2014,8,14, 9,0,0, tzinfo=pytz.timezone("Europe/Berlin")) + uid = self.send_itip_invitation(self.jane['mail'], start) + + response = self.check_message_received('"test" has been ACCEPTED', self.jane['mail']) + self.assertIsInstance(response, email.message.Message) + + event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid) + self.assertIsInstance(event, pykolab.xml.Event) + self.assertEqual(event.get_summary(), "test") + + self.purge_mailbox(self.john['mailbox']) + + # send update with new date and incremented sequence + new_start = datetime.datetime(2014,8,15, 15,0,0, tzinfo=pytz.timezone("Europe/Berlin")) + self.send_itip_update(self.jane['mail'], uid, new_start, summary="test", sequence=1) + + response = self.check_message_received('"test" has been ACCEPTED', self.jane['mail']) + self.assertIsInstance(response, email.message.Message) + + event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid) + self.assertIsInstance(event, pykolab.xml.Event) + self.assertEqual(event.get_start(), new_start) + self.assertEqual(event.get_sequence(), 1) + + + def test_004_invitation_reply(self): + start = datetime.datetime(2014,8,18, 14,30,0, tzinfo=pytz.timezone("Europe/Berlin")) + uid = self.create_calendar_event(start, user=self.john) + + event = self.check_user_calendar_event(self.john['kolabtargetfolder'], uid) + self.assertIsInstance(event, pykolab.xml.Event) + + # send a reply from jane to john + self.send_itip_reply(uid, self.john['mail'], self.jane['mail'], start=start) + + # check for the updated event in john's calendar + time.sleep(10) + event = self.check_user_calendar_event(self.john['kolabtargetfolder'], uid) + self.assertIsInstance(event, pykolab.xml.Event) + + attendee = event.get_attendee(self.jane['mail']) + self.assertIsInstance(attendee, pykolab.xml.Attendee) + self.assertEqual(attendee.get_participant_status(), kolabformat.PartAccepted) +
\ No newline at end of file diff --git a/tests/functional/user_add.py b/tests/functional/user_add.py index 4939f93..b1b37f1 100644 --- a/tests/functional/user_add.py +++ b/tests/functional/user_add.py @@ -4,7 +4,7 @@ from pykolab import wap_client conf = pykolab.getConf() -def user_add(givenname, sn, preferredlanguage='en_US'): +def user_add(givenname, sn, preferredlanguage='en_US', **kw): if givenname == None: raise Exception @@ -25,6 +25,8 @@ def user_add(givenname, sn, preferredlanguage='en_US'): 'userpassword': 'Welcome2KolabSystems' } + user_details.update(kw) + login = conf.get('ldap', 'bind_dn') password = conf.get('ldap', 'bind_pw') domain = conf.get('kolab', 'primary_domain') diff --git a/tests/unit/test-012-wallace_invitationpolicy.py b/tests/unit/test-012-wallace_invitationpolicy.py new file mode 100644 index 0000000..75939d0 --- /dev/null +++ b/tests/unit/test-012-wallace_invitationpolicy.py @@ -0,0 +1,129 @@ +import pykolab +import logging +import datetime + +from icalendar import Calendar +from email import message +from email import message_from_string +from wallace import module_invitationpolicy as MIP +from twisted.trial import unittest + +from pykolab.auth.ldap import LDAP +from pykolab.constants import * + + +# define some iTip MIME messages + +itip_multipart = """MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="=_c8894dbdb8baeedacae836230e3436fd" +From: "Doe, John" <john.doe@example.org> +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: jane.doe@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 1.0.1//NONSGML Calendar//EN +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VEVENT +UID:626421779C777FBE9C9B85A80D04DDFA-A4BF5BBB9FEAA271 +DTSTAMP:20120713T1254140 +DTSTART;TZID=3DEurope/London:20120713T100000 +DTEND;TZID=3DEurope/London:20120713T110000 +SUMMARY:test +DESCRIPTION:test +ORGANIZER;CN=3D"Doe, John":mailto:john.doe@example.org +ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailt= +o:jane.doe@example.org +ATTENDEE;ROLE=3DOPT-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailt= +user.external@example.com +SEQUENCE:1 +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR + +--=_c8894dbdb8baeedacae836230e3436fd-- +""" + +conf = pykolab.getConf() + +if not hasattr(conf, 'defaults'): + conf.finalize_conf() + +class TestWallaceInvitationpolicy(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_user_dn", self._mock_find_user_dn) + #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) + + # 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, "sendmail", self._mock_smtp_sendmail) + + self.smtplog = []; + + def _mock_find_user_dn(self, value, kolabuser=False): + (prefix, domain) = value.split('@') + return "uid=" + prefix + ",ou=People,dc=" + ",dc=".join(domain.split('.')) + + 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_nop(self, domain=None): + pass + + 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)) + + def test_001_itip_events_from_message(self): + itips = pykolab.itip.events_from_message(message_from_string(itip_multipart)) + self.assertEqual(len(itips), 1, "Multipart iTip message with text/calendar") + self.assertEqual(itips[0]['method'], "REQUEST", "iTip request method property") + self.assertEqual(len(itips[0]['attendees']), 2, "List attendees from iTip") + self.assertEqual(itips[0]['attendees'][0], "mailto:jane.doe@example.org", "First attendee from iTip") + + def test_002_user_dn_from_email_address(self): + res = MIP.user_dn_from_email_address("doe@example.org") + # assert call to (patched) pykolab.auth.Auth.find_resource() + self.assertEqual("uid=doe,ou=People,dc=example,dc=org", res); + + def test_003_get_matching_invitation_policy(self): + user = { 'kolabinvitationpolicy': [ + 'ACT_ACCEPT:example.org', + 'ACT_REJECT:gmail.com', + 'ACT_MANUAL:*' + ] } + self.assertEqual(MIP.get_matching_invitation_policies(user, 'fastmail.net'), [MIP.ACT_MANUAL]) + self.assertEqual(MIP.get_matching_invitation_policies(user, 'example.org'), [MIP.ACT_ACCEPT,MIP.ACT_MANUAL]) + self.assertEqual(MIP.get_matching_invitation_policies(user, 'gmail.com'), [MIP.ACT_REJECT,MIP.ACT_MANUAL]) + + user = { 'kolabinvitationpolicy': ['ACT_ACCEPT:example.org', 'ACT_MANUAL:others'] } + self.assertEqual(MIP.get_matching_invitation_policies(user, 'somedomain.net'), [MIP.ACT_MANUAL]) diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py new file mode 100644 index 0000000..b5863c2 --- /dev/null +++ b/wallace/module_invitationpolicy.py @@ -0,0 +1,721 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) +# +# Thomas Bruederli (Kolab Systems) <bruederli@kolabsys.com> +# +# 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 <http://www.gnu.org/licenses/>. +# + +import datetime +import os +import tempfile +import time +from urlparse import urlparse +import urllib + +from email import message_from_string +from email.parser import Parser +from email.utils import formataddr +from email.utils import getaddresses + +import modules + +import pykolab +import kolabformat + +from pykolab.auth import Auth +from pykolab.conf import Conf +from pykolab.imap import IMAP +from pykolab.xml import to_dt +from pykolab.xml import event_from_string +from pykolab.itip import events_from_message +from pykolab.itip import check_event_conflict +from pykolab.itip import send_reply +from pykolab.translate import _ + +# define some contstants used in the code below +MOD_IF_AVAILABLE = 32 +MOD_IF_CONFLICT = 64 +MOD_TENTATIVE = 128 +MOD_NOTIFY = 256 +ACT_MANUAL = 1 +ACT_ACCEPT = 2 +ACT_DELEGATE = 4 +ACT_REJECT = 8 +ACT_UPDATE = 16 +ACT_TENTATIVE = ACT_ACCEPT + MOD_TENTATIVE +ACT_ACCEPT_IF_NO_CONFLICT = ACT_ACCEPT + MOD_IF_AVAILABLE +ACT_TENTATIVE_IF_NO_CONFLICT = ACT_ACCEPT + MOD_TENTATIVE + MOD_IF_AVAILABLE +ACT_DELEGATE_IF_CONFLICT = ACT_DELEGATE + MOD_IF_CONFLICT +ACT_REJECT_IF_CONFLICT = ACT_REJECT + MOD_IF_CONFLICT +ACT_UPDATE_AND_NOTIFY = ACT_UPDATE + MOD_NOTIFY + +FOLDER_TYPE_ANNOTATION = '/vendor/kolab/folder-type' + +MESSAGE_PROCESSED = 1 +MESSAGE_FORWARD = 2 + +policy_name_map = { + 'ACT_MANUAL': ACT_MANUAL, + 'ACT_ACCEPT': ACT_ACCEPT, + 'ACT_ACCEPT_IF_NO_CONFLICT': ACT_ACCEPT_IF_NO_CONFLICT, + 'ACT_TENTATIVE': ACT_TENTATIVE, + 'ACT_TENTATIVE_IF_NO_CONFLICT': ACT_TENTATIVE_IF_NO_CONFLICT, + 'ACT_DELEGATE': ACT_DELEGATE, + 'ACT_DELEGATE_IF_CONFLICT': ACT_DELEGATE_IF_CONFLICT, + 'ACT_REJECT': ACT_REJECT, + 'ACT_REJECT_IF_CONFLICT': ACT_REJECT_IF_CONFLICT, + 'ACT_UPDATE': ACT_UPDATE, + 'ACT_UPDATE_AND_NOTIFY': ACT_UPDATE_AND_NOTIFY +} + +policy_value_map = dict([(v, k) for (k, v) in policy_name_map.iteritems()]) + +log = pykolab.getLogger('pykolab.wallace') +conf = pykolab.getConf() + +mybasepath = '/var/spool/pykolab/wallace/invitationpolicy/' + +auth = None +imap = None + +def __init__(): + modules.register('invitationpolicy', execute, description=description()) + +def accept(filepath): + new_filepath = os.path.join( + mybasepath, + 'ACCEPT', + os.path.basename(filepath) + ) + + cleanup() + os.rename(filepath, new_filepath) + filepath = new_filepath + exec('modules.cb_action_ACCEPT(%r, %r)' % ('invitationpolicy',filepath)) + +def reject(filepath): + new_filepath = os.path.join( + mybasepath, + 'REJECT', + os.path.basename(filepath) + ) + + os.rename(filepath, new_filepath) + filepath = new_filepath + exec('modules.cb_action_REJECT(%r, %r)' % ('invitationpolicy',filepath)) + +def description(): + return """Invitation policy execution module.""" + +def cleanup(): + global auth, imap + + log.debug("cleanup(): %r, %r" % (auth, imap), level=9) + + auth.disconnect() + del auth + + # Disconnect IMAP or we lock the mailbox almost constantly + imap.disconnect() + del imap + +def execute(*args, **kw): + global auth, imap + + 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(_("Invitation policy called for %r, %r") % (args, kw), level=9) + + auth = Auth() + imap = IMAP() + + # TODO: Test for correct call. + filepath = args[0] + + if kw.has_key('stage'): + log.debug(_("Issuing callback after processing to stage %s") % (kw['stage']), level=8) + + log.debug(_("Testing cb_action_%s()") % (kw['stage']), level=8) + if hasattr(modules, 'cb_action_%s' % (kw['stage'])): + log.debug(_("Attempting to execute cb_action_%s()") % (kw['stage']), level=8) + + exec( + 'modules.cb_action_%s(%r, %r)' % ( + kw['stage'], + 'invitationpolicy', + filepath + ) + ) + + return filepath + else: + # Move to incoming + new_filepath = os.path.join( + mybasepath, + 'incoming', + os.path.basename(filepath) + ) + + if not filepath == new_filepath: + log.debug("Renaming %r to %r" % (filepath, new_filepath)) + os.rename(filepath, new_filepath) + filepath = new_filepath + + # parse full message + message = Parser().parse(open(filepath, 'r')) + + recipients = [address for displayname,address in getaddresses(message.get_all('X-Kolab-To'))] + sender_email = [address for displayname,address in getaddresses(message.get_all('X-Kolab-From'))][0] + + any_itips = False + recipient_email = None + recipient_user_dn = None + + # An iTip message may contain multiple events. Later on, test if the message + # is an iTip message by checking the length of this list. + try: + itip_events = events_from_message(message, ['REQUEST', 'REPLY', 'CANCEL']) + except Exception, e: + log.error(_("Failed to parse iTip events from message: %r" % (e))) + itip_events = [] + + if not len(itip_events) > 0: + log.info(_("Message is not an iTip message or does not contain any (valid) iTip events.")) + + else: + any_itips = True + log.debug(_("iTip events attached to this message contain the following information: %r") % (itip_events), level=9) + + # See if any iTip actually allocates a user. + if any_itips and len([x['uid'] for x in itip_events if x.has_key('attendees') or x.has_key('organizer')]) > 0: + auth.connect() + + for recipient in recipients: + recipient_user_dn = user_dn_from_email_address(recipient) + if recipient_user_dn is not None: + recipient_email = recipient + break + + if not any_itips: + log.debug(_("No itips, no users, pass along %r") % (filepath), level=5) + return filepath + elif recipient_email is None: + log.debug(_("iTips, but no users, pass along %r") % (filepath), level=5) + return filepath + + # we're looking at the first itip event object + itip_event = itip_events[0]; + + # for replies, the organizer is the recipient + if itip_event['method'] == 'REPLY': + user_attendees = [itip_event['organizer']] if str(itip_event['organizer']).split(':')[-1] == recipient_email else [] + + else: + # Limit the attendees to the one that is actually invited with the current message. + attendees = [str(a).split(':')[-1] for a in (itip_event['attendees'] if itip_event.has_key('attendees') else [])] + user_attendees = [a for a in attendees if a == recipient_email] + + if itip_event.has_key('organizer'): + sender_email = itip_event['xml'].get_organizer().email() + + # abort if no attendee matches the envelope recipient + if len(user_attendees) == 0: + log.info(_("No user attendee matching envelope recipient %s, skip message") % (recipient_email)) + return filepath + + receiving_user = auth.get_entry_attributes(None, recipient_user_dn, ['*']) + log.debug(_("Receiving user: %r") % (receiving_user), level=9) + + # find user's kolabInvitationPolicy settings and the matching policy values + sender_domain = str(sender_email).split('@')[-1] + policies = get_matching_invitation_policies(receiving_user, sender_domain) + + # select a processing function according to the iTip request method + method_processing_map = { + 'REQUEST': process_itip_request, + 'REPLY': process_itip_reply, + 'CANCEL': process_itip_cancel + } + + done = None + if method_processing_map.has_key(itip_event['method']): + processor_func = method_processing_map[itip_event['method']] + + # connect as cyrus-admin + imap.connect() + + for policy in policies: + log.debug(_("Apply invitation policy %r for domain %r") % (policy_value_map[policy], sender_domain), level=8) + done = processor_func(itip_event, policy, recipient_email, sender_email, receiving_user) + + # matching policy found + if done is not None: + break + + else: + log.debug(_("Ignoring '%s' iTip method") % (itip_event['method']), level=8) + + # message has been processed by the module, remove it + if done == MESSAGE_PROCESSED: + log.debug(_("iTip message %r consumed by the invitationpolicy module") % (message.get('Message-ID')), level=5) + os.unlink(filepath) + filepath = None + + cleanup() + return filepath + + +def process_itip_request(itip_event, policy, recipient_email, sender_email, receiving_user): + """ + Process an iTip REQUEST message according to the given policy + """ + + # if invitation policy is set to MANUAL, pass message along + if policy & ACT_MANUAL: + log.info(_("Pass invitation for manual processing")) + return MESSAGE_FORWARD + + try: + receiving_attendee = itip_event['xml'].get_attendee_by_email(recipient_email) + log.debug(_("Receiving Attendee: %r") % (receiving_attendee), level=9) + except Exception, e: + log.error("Could not find envelope attendee: %r" % (e)) + return MESSAGE_FORWARD + + # process request to participating attendees with RSVP=TRUE or PARTSTAT=NEEDS-ACTION + nonpart = receiving_attendee.get_role() == kolabformat.NonParticipant + partstat = receiving_attendee.get_participant_status() + save_event = not nonpart or not partstat == kolabformat.PartNeedsAction + scheduling_required = receiving_attendee.get_rsvp() or partstat == kolabformat.PartNeedsAction + condition_fulfilled = True + + # find existing event in user's calendar + existing = find_existing_event(itip_event, receiving_user) + + # compare sequence number to determine a (re-)scheduling request + if existing is not None: + log.debug(_("Existing event: %r") % (existing), level=9) + scheduling_required = itip_event['sequence'] > 0 and itip_event['sequence'] >= existing.get_sequence() + save_event = True + + # if scheduling: check availability + if scheduling_required: + if policy & (MOD_IF_AVAILABLE | MOD_IF_CONFLICT): + condition_fulfilled = check_availability(itip_event, receiving_user) + if policy & MOD_IF_CONFLICT: + condition_fulfilled = not condition_fulfilled + + log.debug(_("Precondition for event %r fulfilled: %r") % (itip_event['uid'], condition_fulfilled), level=5) + + # if RSVP, send an iTip REPLY + if scheduling_required: + respond_with = None + if policy & ACT_ACCEPT and condition_fulfilled: + respond_with = 'TENTATIVE' if policy & MOD_TENTATIVE else 'ACCEPTED' + + elif policy & ACT_REJECT and condition_fulfilled: + respond_with = 'DECLINED' + # TODO: only save declined invitation when a certain config option is set? + + elif policy & ACT_DELEGATE and condition_fulfilled: + # TODO: save and delegate (but to whom?) + pass + + # send iTip reply + if respond_with is not None: + # set attendee's CN from LDAP record if yet missing + if not receiving_attendee.get_name() and receiving_user.has_key('cn'): + receiving_attendee.set_name(receiving_user['cn']) + + receiving_attendee.set_participant_status(respond_with) + send_reply(recipient_email, itip_event, invitation_response_text(), + subject=_('"%(summary)s" has been %(status)s')) + + else: + # policy doesn't match, pass on to next one + return None + + else: + log.debug(_("No RSVP for recipient %r requested") % (receiving_user['mail']), level=8) + # TODO: only update if policy & ACT_UPDATE ? + + if save_event: + targetfolder = None + + if existing: + # delete old version from IMAP + targetfolder = existing._imap_folder + delete_event(existing) + + if not nonpart or existing: + # save new copy from iTip + if store_event(itip_event['xml'], receiving_user, targetfolder): + return MESSAGE_PROCESSED + + return None + + +def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiving_user): + """ + Process an iTip REPLY message according to the given policy + """ + + # if invitation policy is set to MANUAL, pass message along + if policy & ACT_MANUAL: + log.info(_("Pass reply for manual processing")) + return MESSAGE_FORWARD + + # auto-update is enabled for this user + if policy & ACT_UPDATE: + try: + sender_attendee = itip_event['xml'].get_attendee_by_email(sender_email) + log.debug(_("Sender Attendee: %r") % (sender_attendee), level=9) + except Exception, e: + log.error("Could not find envelope sender attendee: %r" % (e)) + return MESSAGE_FORWARD + + # find existing event in user's calendar + existing = find_existing_event(itip_event, receiving_user) + + if existing: + log.debug(_("Auto-updating event %r on iTip REPLY") % (existing.uid), level=8) + + # TODO: compare sequence number to avoid outdated replies? + try: + existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status()) + except Exception, e: + log.error("Could not find corresponding attende in organizer's event: %r" % (e)) + + # TODO: accept new participant if ACT_ACCEPT ? + return MESSAGE_FORWARD + + # update the organizer's copy of the event + delete_event(existing) + if store_event(existing, receiving_user, existing._imap_folder): + # TODO: send (consolidated) notification to organizer if policy & ACT_UPDATE_AND_NOTIFY: + # TODO: update all other attendee's copies if conf.get('wallace','invitationpolicy_autoupdate_other_attendees_on_reply'): + return MESSAGE_PROCESSED + + else: + log.error(_("The event referred by this reply was not found in the user's calendars. Forwarding to Inbox.")) + return MESSAGE_FORWARD + + return None + + +def process_itip_cancel(itip_event, policy, recipient_email, sender_email, receiving_user): + """ + Process an iTip CANCEL message according to the given policy + """ + + # if invitation policy is set to MANUAL, pass message along + if policy & ACT_MANUAL: + log.info(_("Pass cancellation for manual processing")) + return MESSAGE_FORWARD + + # update_event_in_user_calendar(itip_event, receiving_user) + + return MESSAGE_PROCESSED + + +def user_dn_from_email_address(email_address): + """ + Resolves the given email address to a Kolab user entity + """ + global auth + + if not auth: + auth = Auth() + auth.connect() + + local_domains = auth.list_domains() + + if not local_domains == None: + local_domains = list(set(local_domains.keys())) + + if not email_address.split('@')[1] in local_domains: + return None + + log.debug(_("Checking if email address %r belongs to a local user") % (email_address), level=8) + + user_dn = auth.find_user_dn(email_address, True) + + if isinstance(user_dn, basestring): + log.debug(_("User DN: %r") % (user_dn), level=8) + else: + log.debug(_("No user record(s) found for %r") % (email_address), level=9) + + auth.disconnect() + + return user_dn + + +def get_matching_invitation_policies(receiving_user, sender_domain): + # get user's kolabInvitationPolicy settings + policies = receiving_user['kolabinvitationpolicy'] if receiving_user.has_key('kolabinvitationpolicy') else [] + if policies and not isinstance(policies, list): + policies = [policies] + + if len(policies) == 0: + policies = conf.get_list('wallace', 'kolab_invitation_policy') + + # match policies agains the given sender_domain + matches = [] + for p in policies: + if ':' in p: + (value, domain) = p.split(':') + else: + value = p + domain = '' + + if domain == '' or domain == '*' or sender_domain.endswith(domain): + value = value.upper() + if policy_name_map.has_key(value): + matches.append(policy_name_map[value]) + + # add manual as default action + if len(matches) == 0: + matches.append(ACT_MANUAL) + + return matches + + +def imap_proxy_auth(user_rec): + """ + + """ + global imap + + mail_attribute = conf.get('cyrus-sasl', 'result_attribute') + if mail_attribute == None: + mail_attribute = 'mail' + + mail_attribute = mail_attribute.lower() + + if not user_rec.has_key(mail_attribute): + log.error(_("User record doesn't have the mailbox attribute %r set" % (mail_attribute))) + return False + + # do IMAP prox auth with the given user + backend = conf.get('kolab', 'imap_backend') + admin_login = conf.get(backend, 'admin_login') + admin_password = conf.get(backend, 'admin_password') + + try: + imap.disconnect() + imap.connect(login=False) + imap.login_plain(admin_login, admin_password, user_rec[mail_attribute]) + except Exception, errmsg: + log.error(_("IMAP proxy authentication failed: %r") % (errmsg)) + return False + + return True + + +def list_user_calendars(user_rec): + """ + Get a list of the given user's private calendar folders + """ + global imap + + # return cached list + if user_rec.has_key('_calendar_folders'): + return user_rec['_calendar_folders']; + + calendars = [] + + if not imap_proxy_auth(user_rec): + return calendars + + folders = imap.list_folders('*') + log.debug(_("List calendar folders for user %r: %r") % (user_rec['mail'], folders), level=8) + + (ns_personal, ns_other, ns_shared) = imap.namespaces() + + if isinstance(ns_shared, list): + ns_shared = ns_shared[0] + if isinstance(ns_other, list): + ns_other = ns_other[0] + + for folder in folders: + # exclude shared and other user's namespace + # TODO: list shared folders the user has write privileges ? + if folder.startswith(ns_other) or folder.startswith(ns_shared): + continue; + + metadata = imap.get_metadata(folder) + log.debug(_("IMAP metadata for %r: %r") % (folder, metadata), level=9) + if metadata.has_key(folder) and ( \ + metadata[folder].has_key('/shared' + FOLDER_TYPE_ANNOTATION) and metadata[folder]['/shared' + FOLDER_TYPE_ANNOTATION].startswith('event') \ + or metadata[folder].has_key('/private' + FOLDER_TYPE_ANNOTATION) and metadata[folder]['/private' + FOLDER_TYPE_ANNOTATION].startswith('event')): + calendars.append(folder) + + # store default calendar folder in user record + if metadata[folder].has_key('/private' + FOLDER_TYPE_ANNOTATION) and metadata[folder]['/private' + FOLDER_TYPE_ANNOTATION].endswith('.default'): + user_rec['_default_calendar'] = folder + + # cache with user record + user_rec['_calendar_folders'] = calendars + + return calendars + + +def find_existing_event(itip_event, user_rec): + """ + Search user's calendar folders for the given event (by UID) + """ + global imap + + event = None + for folder in list_user_calendars(user_rec): + log.debug(_("Searching folder %r for event %r") % (folder, itip_event['uid']), level=8) + imap.imap.m.select(imap.folder_utf7(folder)) + + typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (itip_event['uid'])) + for num in reversed(data[0].split()): + typ, data = imap.imap.m.fetch(num, '(RFC822)') + + event_message = message_from_string(data[0][1]) + + if event_message.is_multipart(): + for part in event_message.walk(): + if part.get_content_type() == "application/calendar+xml": + payload = part.get_payload(decode=True) + event = event_from_string(payload) + setattr(event, '_imap_folder', folder) + break + + if event and event.uid == itip_event['uid']: + return event + + return event + + +def check_availability(itip_event, receiving_user): + """ + For the receiving user, determine if the event in question is in conflict. + """ + + start = time.time() + num_messages = 0 + conflict = False + + # return previously detected conflict + if itip_event.has_key('_conflicts'): + return not itip_event['_conflicts'] + + for folder in list_user_calendars(receiving_user): + log.debug(_("Listing events from folder %r") % (folder), level=8) + imap.imap.m.select(imap.folder_utf7(folder)) + + typ, data = imap.imap.m.search(None, '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")') + num_messages += len(data[0].split()) + + for num in reversed(data[0].split()): + event = None + typ, data = imap.imap.m.fetch(num, '(RFC822)') + + event_message = message_from_string(data[0][1]) + + if event_message.is_multipart(): + for part in event_message.walk(): + if part.get_content_type() == "application/calendar+xml": + payload = part.get_payload(decode=True) + event = event_from_string(payload) + break + + if event and event.uid: + conflict = check_event_conflict(event, itip_event) + if conflict: + log.info(_("Existing event %r conflicts with invitation %r") % (event.uid, itip_event['uid'])) + break + + if conflict: + break + + end = time.time() + log.debug(_("start: %r, end: %r, total: %r, messages: %d") % (start, end, (end-start), num_messages), level=9) + + # remember the result of this check for further iterations + itip_event['_conflicts'] = conflict + + return not conflict + + +def store_event(event, user_rec, targetfolder=None): + """ + Append the given event object to the user's default calendar + """ + + # find default calendar folder to save event to + if targetfolder is None: + targetfolder = list_user_calendars(user_rec)[0] + if user_rec.has_key('_default_calendar'): + targetfolder = user_rec['_default_calendar'] + + if not targetfolder: + log.error(_("Failed to save event: no calendar folder found for user %r") % (user_rec['mail'])) + return Fasle + + log.debug(_("Save event %r to user calendar %r") % (event.uid, targetfolder), level=8) + + try: + imap.imap.m.select(imap.folder_utf7(targetfolder)) + result = imap.imap.m.append( + imap.folder_utf7(targetfolder), + None, + None, + event.to_message().as_string() + ) + return result + + except Exception, e: + log.error(_("Failed to save event to user calendar at %r: %r") % ( + targetfolder, e + )) + + return False + + +def delete_event(existing): + """ + Removes the IMAP object with the given UID from a user's calendar folder + """ + targetfolder = existing._imap_folder + imap.imap.m.select(imap.folder_utf7(targetfolder)) + + typ, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % existing.uid) + + log.debug(_("Delete event %r in %r: %r") % ( + existing.uid, targetfolder, data + ), level=8) + + for num in data[0].split(): + imap.imap.m.store(num, '+FLAGS', '\\Deleted') + + imap.imap.m.expunge() + + +def invitation_response_text(): + return _(""" + %(name)s has %(status)s your invitation for %(summary)s. + + *** This is an automated response sent by the Kolab Invitation system *** + """) |