diff --git a/plugins/libcalendaring/lib/libcalendaring_recurrence.php b/plugins/libcalendaring/lib/libcalendaring_recurrence.php
index 4aca2861..4de96812 100644
--- a/plugins/libcalendaring/lib/libcalendaring_recurrence.php
+++ b/plugins/libcalendaring/lib/libcalendaring_recurrence.php
@@ -1,278 +1,278 @@
 <?php
 
 use Sabre\VObject\Recur\EventIterator;
 
 /**
  * Recurrence computation class for shared use.
  *
  * Uitility class to compute recurrence dates from the given rules.
  *
  * @author Aleksander Machniak <machniak@apheleia-it.ch
  *
  * Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero 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 Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 class libcalendaring_recurrence
 {
     protected $lib;
     protected $start;
     protected $engine;
     protected $recurrence;
     protected $dateonly = false;
     protected $event;
     protected $duration;
 
     /**
      * Default constructor
      *
      * @param libcalendaring $lib   The libcalendaring plugin instance
      * @param array          $event The event object to operate on
      */
     function __construct($lib, $event = null)
     {
         $this->lib   = $lib;
         $this->event = $event;
 
         if (!empty($event)) {
             if (!empty($event['start']) && is_object($event['start'])
                 && !empty($event['end']) && is_object($event['end'])
             ) {
                 $this->duration = $event['start']->diff($event['end']);
             }
 
             $event['start']->_dateonly = !empty($event['allday']);
 
             $this->init($event['recurrence'], $event['start']);
         }
     }
 
     /**
      * Initialize recurrence engine
      *
      * @param array    The recurrence properties
      * @param DateTime The recurrence start date
      */
     public function init($recurrence, $start)
     {
         $this->start      = $start;
         $this->dateonly   = !empty($start->_dateonly);
         $this->recurrence = $recurrence;
 
         $event = [
             'uid' => '1',
             'allday' => $this->dateonly,
             'recurrence' => $recurrence,
             'start' => $start,
             // TODO: END/DURATION ???
             // TODO: moved occurrences ???
         ];
 
         $vcalendar = new libcalendaring_vcalendar($this->lib->timezone);
 
         $ve = $vcalendar->toSabreComponent($event);
 
         $this->engine = new EventIterator($ve, null, $this->lib->timezone);
     }
 
     /**
      * Get date/time of the next occurence of this event, and push the iterator.
      *
      * @return DateTime|false object or False if recurrence ended
      */
     public function next_start()
     {
         try {
             $this->engine->next();
             $current = $this->engine->getDtStart();
         }
         catch (Exception $e) {
             // do nothing
         }
 
         return $current ? $this->toDateTime($current) : false;
     }
 
     /**
      * Get the next recurring instance of this event
      *
      * @return array|false Array with event properties or False if recurrence ended
      */
     public function next_instance()
     {
         if ($next_start = $this->next_start()) {
             $next = $this->event;
             $next['start'] = $next_start;
 
             if ($this->duration) {
                 $next['end'] = clone $next_start;
                 $next['end']->add($this->duration);
             }
 
             $next['recurrence_date'] = clone $next_start;
             $next['_instance'] = libcalendaring::recurrence_instance_identifier($next, !empty($this->event['allday']));
 
             unset($next['_formatobj']);
 
             return $next;
         }
 
         return false;
     }
 
     /**
      * Get the date of the end of the last occurrence of this recurrence cycle
      *
      * @return DateTime|false End datetime of the last occurrence or False if there's no end date
      */
     public function end()
     {
         // recurrence end date is given
         if (isset($this->recurrence['UNTIL']) && $this->recurrence['UNTIL'] instanceof DateTimeInterface) {
             return $this->recurrence['UNTIL'];
         }
 
-        if (!$this->engine->isInfinite()) {
-            // run through all items till we reach the end
-            try {
-                foreach ($this->engine as $end) {
-                    // do nothing
-                }
-            }
-            catch (Exception $e) {
+        // Run through all items till we reach the end, or limit of iterations
+        // Note: Sabre has a limits of iteration in VObject\Settings, so it is not an infinite loop
+        try {
+            foreach ($this->engine as $end) {
                 // do nothing
             }
         }
-        else if (isset($this->event['end']) && $this->event['end'] instanceof DateTimeInterface) {
+        catch (Exception $e) {
+            // do nothing
+        }
+/*
+        if (empty($end) && isset($this->event['start']) && $this->event['start'] instanceof DateTimeInterface) {
             // determine a reasonable end date if none given
-            $end = clone $this->event['end'];
+            $end = clone $this->event['start'];
             $end->add(new DateInterval('P100Y'));
         }
-
-        return isset($end) ? $this->toDateTime($end, false) : false;
+*/
+        return isset($end) ? $this->toDateTime($end) : false;
     }
 
     /**
      * Find date/time of the first occurrence (excluding start date)
      *
      * @return DateTime|null First occurrence
      */
     public function first_occurrence()
     {
         $start    = clone $this->start;
         $interval = $this->recurrence['INTERVAL'] ?? 1;
         $freq     = $this->recurrence['FREQ'] ?? null;
 
         switch ($freq) {
         case 'WEEKLY':
             if (empty($this->recurrence['BYDAY'])) {
                 return $start;
             }
 
             $start->sub(new DateInterval("P{$interval}W"));
             break;
 
         case 'MONTHLY':
             if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTHDAY'])) {
                 return $start;
             }
 
             $start->sub(new DateInterval("P{$interval}M"));
             break;
 
         case 'YEARLY':
             if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTH'])) {
                 return $start;
             }
 
             $start->sub(new DateInterval("P{$interval}Y"));
             break;
 
         case 'DAILY':
             if (!empty($this->recurrence['BYMONTH'])) {
                 break;
             }
 
         default:
             return $start;
         }
 
         $recurrence = $this->recurrence;
 
         if (!empty($recurrence['COUNT'])) {
             // Increase count so we do not stop the loop to early
             $recurrence['COUNT'] += 100;
         }
 
         // Create recurrence that starts in the past
         $self = new self($this->lib);
         $self->init($recurrence, $start);
 
         // TODO: This method does not work the same way as the kolab_date_recurrence based on
         //       kolabcalendaring. I.e. if an event start date does not match the recurrence rule
         //       it will be returned, kolab_date_recurrence will return the next occurrence in such a case
         //       which is the intended result of this function.
         //       See some commented out test cases in tests/RecurrenceTest.php
 
         // find the first occurrence
         $found = false;
         while ($next = $self->next_start()) {
             $start = $next;
             if ($next >= $this->start) {
                 $found = true;
                 break;
             }
         }
 
         if (!$found) {
             rcube::raise_error(
                 [
                     'file' => __FILE__, 'line' => __LINE__,
                     'message' => sprintf("Failed to find a first occurrence. Start: %s, Recurrence: %s",
                         $this->start->format(DateTime::ISO8601), json_encode($recurrence)),
                 ],
                 true
             );
 
             return null;
         }
 
         return $this->toDateTime($start);
     }
 
     /**
      * Convert any DateTime into libcalendaring_datetime
      */
     protected function toDateTime($date, $useStart = true)
     {
         if ($date instanceof DateTimeInterface) {
             $date = libcalendaring_datetime::createFromFormat(
                 'Y-m-d\\TH:i:s',
                 $date->format('Y-m-d\\TH:i:s'),
                 // Sabre will loose timezone on all-day events, use the event start's timezone
                 $this->start->getTimezone()
             );
         }
 
         $date->_dateonly = $this->dateonly;
 
         if ($useStart && $this->dateonly) {
             // Sabre sets time to 00:00:00 for all-day events,
             // let's copy the time from the event's start
             $date->setTime((int) $this->start->format('H'), (int) $this->start->format('i'), (int) $this->start->format('s'));
         }
 
         return $date;
     }
 }
diff --git a/plugins/libcalendaring/tests/RecurrenceTest.php b/plugins/libcalendaring/tests/RecurrenceTest.php
index cdbb2a8d..d17257a8 100644
--- a/plugins/libcalendaring/tests/RecurrenceTest.php
+++ b/plugins/libcalendaring/tests/RecurrenceTest.php
@@ -1,273 +1,337 @@
 <?php
 
 /**
  * libcalendaring_recurrence tests
  *
  * @author Aleksander Machniak <machniak@apheleia-it.ch>
  *
  * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero 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 Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 
 class RecurrenceTest extends PHPUnit\Framework\TestCase
 {
     private $plugin;
 
     function setUp(): void
     {
         $rcube = rcmail::get_instance();
         $rcube->plugins->load_plugin('libcalendaring', true, true);
 
         $this->plugin = $rcube->plugins->get_plugin('libcalendaring');
     }
 
     /**
-     * Test for libcalendaring_recurrence::first_occurrence()
-     *
-     * @dataProvider data_first_occurrence
+     * Data for test_end()
      */
-    function test_first_occurrence($recurrence_data, $start, $expected)
+    function data_end()
     {
-        $start = new DateTime($start);
-        if (!empty($recurrence_data['UNTIL'])) {
-            $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']);
-        }
-
-        $recurrence = $this->plugin->get_recurrence();
-
-        $recurrence->init($recurrence_data, $start);
-        $first = $recurrence->first_occurrence();
+        return [
+            // non-recurring
+            [
+                [
+                    'recurrence' => [],
+                    'start' => new DateTime('2017-08-31 11:00:00')
+                ],
+                '2017-08-31 11:00:00', // expected result
+            ],
+            // daily
+            [
+                [
+                    'recurrence' => ['FREQ' => 'DAILY', 'INTERVAL' => '1', 'COUNT' => 2],
+                    'start' => new DateTime('2017-08-31 11:00:00')
+                ],
+                '2017-09-01 11:00:00',
+            ],
+            // weekly
+            [
+                [
+                    'recurrence' => ['FREQ' => 'WEEKLY', 'COUNT' => 3],
+                    'start' => new DateTime('2017-08-31 11:00:00'), // Thursday
+                ],
+                '2017-09-14 11:00:00',
+            ],
+            // UNTIL
+            [
+                [
+                    'recurrence' => ['FREQ' => 'WEEKLY', 'COUNT' => 3, 'UNTIL' => new DateTime('2017-09-07 11:00:00')],
+                    'start' => new DateTime('2017-08-31 11:00:00'), // Thursday
+                ],
+                '2017-09-07 11:00:00',
+            ],
+            // Infinite recurrence, no count, no until
+            [
+                [
+                    'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'],
+                    'start' => new DateTime('2017-08-31 11:00:00'), // Thursday
+                ],
+                '2084-09-21 11:00:00',
+            ],
 
-        $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : '');
+            // TODO: Test an event with EXDATE/RDATEs
+        ];
     }
-
     /**
      * Data for test_first_occurrence()
      */
     function data_first_occurrence()
     {
         // TODO: BYYEARDAY, BYWEEKNO, BYSETPOS, WKST
 
         return array(
             // non-recurring
             array(
                 array(),                                     // recurrence data
                 '2017-08-31 11:00:00',                       // start date
                 '2017-08-31 11:00:00',                       // expected result
             ),
             // daily
             array(
                 array('FREQ' => 'DAILY', 'INTERVAL' => '1'), // recurrence data
                 '2017-08-31 11:00:00',                       // start date
                 '2017-08-31 11:00:00',                       // expected result
             ),
             // TODO: this one is not supported by the Calendar UI
 /*
             array(
                 array('FREQ' => 'DAILY', 'INTERVAL' => '1', 'BYMONTH' => 1),
                 '2017-08-31 11:00:00',
                 '2018-01-01 11:00:00',
             ),
 */
             // weekly
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-06 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'TH'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'FR'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-01 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '2'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '3', 'BYDAY' => 'WE'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-20 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'COUNT' => 1),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-06 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'UNTIL' => '2017-09-01'),
                 '2017-08-31 11:00:00', // Thursday
                 '',
             ),
             // monthly
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1'),
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ),
 /*
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'),
                 '2017-08-31 11:00:00',
                 '2017-09-08 11:00:00',
             ),
 */
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'),
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '1WE'),
                 '2017-08-16 11:00:00',
                 '2017-09-06 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '-1WE'),
                 '2017-08-16 11:00:00',
                 '2017-08-30 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '2'),
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ),
 /*
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'),
                 '2017-08-31 11:00:00',
                 '2017-09-08 11:00:00', // ??????
             ),
 */
             // yearly
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1'),
                 '2017-08-16 12:00:00',
                 '2017-08-16 12:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8'),
                 '2017-08-16 12:00:00',
                 '2017-08-16 12:00:00',
             ),
 /*
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'),
                 '2017-08-16 11:00:00',
                 '2017-12-25 11:00:00',
             ),
 */
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8', 'BYDAY' => '-1MO'),
                 '2017-08-16 11:00:00',
                 '2017-08-28 11:00:00',
             ),
 /*
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'),
                 '2017-08-16 11:00:00',
                 '2018-01-01 11:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1,9', 'BYDAY' => '1MO'),
                 '2017-08-16 11:00:00',
                 '2017-09-04 11:00:00',
             ),
 */
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '2'),
                 '2017-08-16 11:00:00',
                 '2017-08-16 11:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYMONTH' => '8'),
                 '2017-08-16 11:00:00',
                 '2017-08-16 11:00:00',
             ),
 /*
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'),
                 '2017-08-16 11:00:00',
                 '2017-12-25 11:00:00',
             ),
 */
             // on dates (FIXME: do we really expect the first occurrence to be on the start date?)
             array(
                 array('RDATE' => array(new DateTime('2017-08-10 11:00:00 Europe/Warsaw'))),
                 '2017-08-01 11:00:00',
                 '2017-08-01 11:00:00',
             ),
         );
     }
 
+    /**
+     * Test for libcalendaring_recurrence::end()
+     *
+     * @dataProvider data_end
+     */
+    function test_end($event, $expected)
+    {
+        $recurrence = new libcalendaring_recurrence($this->plugin, $event);
+
+        $end = $recurrence->end();
+
+        $this->assertSame($expected, $end ? $end->format('Y-m-d H:i:s') : $end);
+    }
+
+    /**
+     * Test for libcalendaring_recurrence::first_occurrence()
+     *
+     * @dataProvider data_first_occurrence
+     */
+    function test_first_occurrence($recurrence_data, $start, $expected)
+    {
+        $start = new DateTime($start);
+        if (!empty($recurrence_data['UNTIL'])) {
+            $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']);
+        }
+
+        $recurrence = $this->plugin->get_recurrence();
+
+        $recurrence->init($recurrence_data, $start);
+        $first = $recurrence->first_occurrence();
+
+        $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : '');
+    }
+
     /**
      * Test for libcalendaring_recurrence::first_occurrence() for all-day events
      *
      * @dataProvider data_first_occurrence
      */
     function test_first_occurrence_allday($recurrence_data, $start, $expected)
     {
         $start = new libcalendaring_datetime($start);
         $start->_dateonly = true;
 
         if (!empty($recurrence_data['UNTIL'])) {
             $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']);
         }
 
         $recurrence = $this->plugin->get_recurrence();
 
         $recurrence->init($recurrence_data, $start);
         $first = $recurrence->first_occurrence();
 
         $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : '');
 
         if ($expected) {
             $this->assertTrue($first->_dateonly);
         }
     }
 
     /**
      * Test for libcalendaring_recurrence::next_instance()
      */
     function test_next_instance()
     {
         date_default_timezone_set('America/New_York');
 
         $start = new libcalendaring_datetime('2017-08-31 11:00:00', new DateTimeZone('Europe/Berlin'));
         $event = [
             'start'      => $start,
             'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'],
             'allday'     => true,
         ];
 
         $recurrence = new libcalendaring_recurrence($this->plugin, $event);
         $next       = $recurrence->next_instance();
 
         $this->assertEquals($start->format('2017-09-07 H:i:s'), $next['start']->format('Y-m-d H:i:s'), 'Same time');
         $this->assertEquals($start->getTimezone()->getName(), $next['start']->getTimezone()->getName(), 'Same timezone');
         $this->assertTrue($next['start']->_dateonly, '_dateonly flag');
     }
 }
diff --git a/plugins/libkolab/lib/kolab_date_recurrence.php b/plugins/libkolab/lib/kolab_date_recurrence.php
index f2110b03..2aaf5888 100644
--- a/plugins/libkolab/lib/kolab_date_recurrence.php
+++ b/plugins/libkolab/lib/kolab_date_recurrence.php
@@ -1,241 +1,242 @@
 <?php
 
 /**
  * Recurrence computation class for xcal-based Kolab format objects
  *
  * Utility class to compute instances of recurring events.
  * It requires the libcalendaring PHP extension to be installed and loaded.
  *
  * @version @package_version@
  * @author Thomas Bruederli <bruederli@kolabsys.com>
  *
  * Copyright (C) 2012-2016, Kolab Systems AG <contact@kolabsys.com>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero 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 Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 
 class kolab_date_recurrence
 {
     private /* EventCal */ $engine;
     private /* kolab_format_xcal */ $object;
     private /* DateTime */ $start;
     private /* DateTime */ $next;
     private /* cDateTime */ $cnext;
     private /* DateInterval */ $duration;
     private /* bool */ $allday;
 
 
     /**
      * Default constructor
      *
      * @param kolab_format_xcal The Kolab object to operate on
      */
     function __construct($object)
     {
         $data = $object->to_array();
 
         $this->object = $object;
         $this->engine = $object->to_libcal();
         $this->start  = $this->next = $data['start'];
         $this->allday = !empty($data['allday']);
         $this->cnext  = kolab_format::get_datetime($this->next);
 
         if (is_object($data['start']) && is_object($data['end'])) {
             $this->duration = $data['start']->diff($data['end']);
         }
         else {
             // Prevent from errors when end date is not set (#5307) RFC5545 3.6.1
             $seconds = !empty($data['end']) ? ($data['end'] - $data['start']) : 0;
             $this->duration = new DateInterval('PT' . $seconds . 'S');
         }
     }
 
     /**
      * Get date/time of the next occurence of this event
      *
      * @param bool Return a Unix timestamp instead of a DateTime object
      *
      * @return DateTime|int|false Object/unix timestamp or False if recurrence ended
      */
     public function next_start($timestamp = false)
     {
         $time = false;
 
         if ($this->engine && $this->next) {
             if (($cnext = new cDateTime($this->engine->getNextOccurence($this->cnext))) && $cnext->isValid()) {
                 $next = kolab_format::php_datetime($cnext, $this->start->getTimezone());
                 $time = $timestamp ? $next->format('U') : $next;
 
                 if ($this->allday) {
                     // it looks that for allday events the occurrence time
                     // is reset to 00:00:00, this is causing various issues
                     $next->setTime($this->start->format('G'), $this->start->format('i'), $this->start->format('s'));
                     $next->_dateonly = true;
                 }
 
                 $this->cnext = $cnext;
                 $this->next  = $next;
             }
         }
 
         return $time;
     }
 
     /**
      * Get the next recurring instance of this event
      *
      * @return mixed Array with event properties or False if recurrence ended
      */
     public function next_instance()
     {
         if ($next_start = $this->next_start()) {
             $next_end = clone $next_start;
             $next_end->add($this->duration);
 
             $next                    = $this->object->to_array();
             $recurrence_id_format    = libkolab::recurrence_id_format($next);
             $next['start']           = $next_start;
             $next['end']             = $next_end;
             $next['recurrence_date'] = clone $next_start;
             $next['_instance']       = $next_start->format($recurrence_id_format);
 
             unset($next['_formatobj']);
 
             return $next;
         }
 
         return false;
     }
 
     /**
      * Get the end date of the last occurence of this recurrence cycle
      *
      * @return DateTime|bool End datetime of the last event or False if recurrence exceeds limit
      */
     public function end()
     {
         $event = $this->object->to_array();
 
         // recurrence end date is given
-        if ($event['recurrence']['UNTIL'] instanceof DateTimeInterface) {
+        if (isset($event['recurrence']['UNTIL']) && $event['recurrence']['UNTIL'] instanceof DateTimeInterface) {
             return $event['recurrence']['UNTIL'];
         }
 
         // let libkolab do the work
         if ($this->engine && ($cend = $this->engine->getLastOccurrence())
             && ($end_dt = kolab_format::php_datetime(new cDateTime($cend)))
         ) {
             return $end_dt;
         }
 
-        // determine a reasonable end date if none given
-        if (!$event['recurrence']['COUNT'] && $event['end'] instanceof DateTimeInterface) {
-            $end_dt = clone $event['end'];
-            $end_dt->add(new DateInterval('P100Y'));
-
-            return $end_dt;
+        // determine a reasonable end date for an infinite recurrence
+        if (empty($event['recurrence']['COUNT'])) {
+            if (!empty($event['start']) && $event['start'] instanceof DateTimeInterface) {
+                $start_dt = clone $event['start'];
+                $start_dt->add(new DateInterval('P100Y'));
+                return $start_dt;
+            }
         }
 
         return false;
     }
 
     /**
      * Find date/time of the first occurrence
      *
      * @return DateTime|null First occurrence
      */
     public function first_occurrence()
     {
         $event      = $this->object->to_array();
         $start      = clone $this->start;
         $orig_start = clone $this->start;
         $interval   = intval($event['recurrence']['INTERVAL'] ?: 1);
 
         switch ($event['recurrence']['FREQ']) {
         case 'WEEKLY':
             if (empty($event['recurrence']['BYDAY'])) {
                 return $orig_start;
             }
 
             $start->sub(new DateInterval("P{$interval}W"));
             break;
 
         case 'MONTHLY':
             if (empty($event['recurrence']['BYDAY']) && empty($event['recurrence']['BYMONTHDAY'])) {
                 return $orig_start;
             }
 
             $start->sub(new DateInterval("P{$interval}M"));
             break;
 
         case 'YEARLY':
             if (empty($event['recurrence']['BYDAY']) && empty($event['recurrence']['BYMONTH'])) {
                 return $orig_start;
             }
 
             $start->sub(new DateInterval("P{$interval}Y"));
             break;
 
         case 'DAILY':
             if (!empty($event['recurrence']['BYMONTH'])) {
                 break;
             }
 
         default:
             return $orig_start;
         }
 
         $event['start'] = $start;
         $event['recurrence']['INTERVAL'] = $interval;
         if ($event['recurrence']['COUNT']) {
             // Increase count so we do not stop the loop to early
             $event['recurrence']['COUNT'] += 100;
         }
 
         // Create recurrence that starts in the past
         $object_type = $this->object instanceof kolab_format_task ? 'task' : 'event';
         $object      = kolab_format::factory($object_type, 3.0);
         $object->set($event);
         $recurrence = new self($object);
 
         $orig_date = $orig_start->format('Y-m-d');
         $found     = false;
 
         // find the first occurrence
         while ($next = $recurrence->next_start()) {
             $start = $next;
             if ($next->format('Y-m-d') >= $orig_date) {
                 $found = true;
                 break;
             }
         }
 
         if (!$found) {
             rcube::raise_error(array(
                 'file' => __FILE__,
                 'line' => __LINE__,
                 'message' => sprintf("Failed to find a first occurrence. Start: %s, Recurrence: %s",
                     $orig_start->format(DateTime::ISO8601), json_encode($event['recurrence'])),
             ), true);
 
             return null;
         }
 
         if ($this->allday) {
             $start->_dateonly = true;
         }
 
         return $start;
     }
 }
diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php
index 6c5e7a7a..5b0ecbbe 100644
--- a/plugins/libkolab/libkolab.php
+++ b/plugins/libkolab/libkolab.php
@@ -1,388 +1,389 @@
 <?php
 
 /**
  * Kolab core library
  *
  * Plugin to setup a basic environment for the interaction with a Kolab server.
  * Other Kolab-related plugins will depend on it and can use the library classes
  *
  * @version @package_version@
  * @author Thomas Bruederli <bruederli@kolabsys.com>
  *
  * Copyright (C) 2012-2015, Kolab Systems AG <contact@kolabsys.com>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero 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 Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 
 class libkolab extends rcube_plugin
 {
     static $http_requests = array();
     static $bonnie_api    = false;
 
     /**
      * Required startup method of a Roundcube plugin
      */
     public function init()
     {
         // load local config
         $this->load_config();
+        $this->require_plugin('libcalendaring');
 
         // extend include path to load bundled lib classes
         $include_path = $this->home . '/lib' . PATH_SEPARATOR . ini_get('include_path');
         set_include_path($include_path);
 
         $this->add_hook('storage_init', array($this, 'storage_init'));
         $this->add_hook('storage_connect', array($this, 'storage_connect'));
         $this->add_hook('user_delete', array('kolab_storage', 'delete_user_folders'));
 
         // For Chwala
         $this->add_hook('folder_mod', array('kolab_storage', 'folder_mod'));
 
         $rcmail = rcube::get_instance();
         try {
             kolab_format::$timezone = new DateTimeZone($rcmail->config->get('timezone', 'GMT'));
         }
         catch (Exception $e) {
             rcube::raise_error($e, true);
             kolab_format::$timezone = new DateTimeZone('GMT');
         }
 
         $this->add_texts('localization/', false);
 
         if (!empty($rcmail->output->type) && $rcmail->output->type == 'html') {
             $rcmail->output->add_handler('libkolab.folder_search_form', array($this, 'folder_search_form'));
             $this->include_stylesheet($this->local_skin_path() . '/libkolab.css');
         }
 
         // embed scripts and templates for email message audit trail
         if ($rcmail->task == 'mail' && self::get_bonnie_api()) {
             if ($rcmail->output->type == 'html') {
                 $this->add_hook('render_page', array($this, 'bonnie_render_page'));
                 $this->include_script('libkolab.js');
 
                 // add 'Show history' item to message menu
                 $this->api->add_content(html::tag('li', array('role' => 'menuitem'),
                     $this->api->output->button(array(
                         'command'  => 'kolab-mail-history',
                         'label'    => 'libkolab.showhistory',
                         'type'     => 'link',
                         'classact' => 'icon history active',
                         'class'    => 'icon history disabled',
                         'innerclass' => 'icon history',
                     ))),
                     'messagemenu');
             }
 
             $this->register_action('plugin.message-changelog', array($this, 'message_changelog'));
         }
     }
 
     /**
      * Hook into IMAP FETCH HEADER.FIELDS command and request Kolab-specific headers
      */
     function storage_init($p)
     {
         $kolab_headers = 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION MESSAGE-ID';
 
         if (!empty($p['fetch_headers'])) {
             $p['fetch_headers'] .= ' ' . $kolab_headers;
         }
         else {
             $p['fetch_headers'] = $kolab_headers;
         }
 
         return $p;
     }
 
     /**
      * Hook into IMAP connection to replace client identity
      */
     function storage_connect($p)
     {
         $client_name = 'Roundcube/Kolab';
 
         if (empty($p['ident'])) {
             $p['ident'] = array(
                 'name'    => $client_name,
                 'version' => RCUBE_VERSION,
 /*
                 'php'     => PHP_VERSION,
                 'os'      => PHP_OS,
                 'command' => $_SERVER['REQUEST_URI'],
 */
             );
         }
         else {
             $p['ident']['name'] = $client_name;
         }
 
         return $p;
     }
 
     /**
      * Getter for a singleton instance of the Bonnie API
      *
      * @return mixed kolab_bonnie_api instance if configured, false otherwise
      */
     public static function get_bonnie_api()
     {
         // get configuration for the Bonnie API
         if (!self::$bonnie_api && ($bonnie_config = rcube::get_instance()->config->get('kolab_bonnie_api', false))) {
             self::$bonnie_api = new kolab_bonnie_api($bonnie_config);
         }
 
         return self::$bonnie_api;
     }
 
     /**
      * Hook to append the message history dialog template to the mail view
      */
     function bonnie_render_page($p)
     {
         if (($p['template'] === 'mail' || $p['template'] === 'message') && !$p['kolab-audittrail']) {
             // append a template for the audit trail dialog
             $this->api->output->add_footer(
                 html::div(array('id' => 'mailmessagehistory',  'class' => 'uidialog', 'aria-hidden' => 'true', 'style' => 'display:none'),
                     self::object_changelog_table(array('class' => 'records-table changelog-table'))
                 )
             );
             $this->api->output->set_env('kolab_audit_trail', true);
             $p['kolab-audittrail'] = true;
         }
 
         return $p;
     }
 
     /**
      * Handler for message audit trail changelog requests
      */
     public function message_changelog()
     {
         if (!self::$bonnie_api) {
             return false;
         }
 
         $rcmail = rcube::get_instance();
         $msguid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST, true);
         $mailbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST);
 
         $result = $msguid && $mailbox ? self::$bonnie_api->changelog('mail', null, $mailbox, $msguid) : null;
         if (is_array($result)) {
             if (is_array($result['changes'])) {
                 $dtformat = $rcmail->config->get('date_format') . ' ' . $rcmail->config->get('time_format');
                 array_walk($result['changes'], function(&$change) use ($dtformat, $rcmail) {
                   if ($change['date']) {
                       $dt = rcube_utils::anytodatetime($change['date']);
                       if ($dt instanceof DateTimeInterface) {
                           $change['date'] = $rcmail->format_date($dt, $dtformat);
                       }
                   }
                 });
             }
             $this->api->output->command('plugin.message_render_changelog', $result['changes']);
         }
         else {
             $this->api->output->command('plugin.message_render_changelog', false);
         }
 
         $this->api->output->send();
     }
 
     /**
      * Wrapper function to load and initalize the HTTP_Request2 Object
      *
      * @param string|Net_Url2 Request URL
      * @param string          Request method ('OPTIONS','GET','HEAD','POST','PUT','DELETE','TRACE','CONNECT')
      * @param array           Configuration for this Request instance, that will be merged
      *                        with default configuration
      *
      * @return HTTP_Request2 Request object
      */
     public static function http_request($url = '', $method = 'GET', $config = array())
     {
         $rcube       = rcube::get_instance();
         $http_config = (array) $rcube->config->get('kolab_http_request');
 
         // deprecated configuration options
         if (empty($http_config)) {
             foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) {
                 $value = $rcube->config->get('kolab_' . $option, true);
                 if (is_bool($value)) {
                     $http_config[$option] = $value;
                 }
             }
         }
 
         if (!empty($config)) {
             $http_config = array_merge($http_config, $config);
         }
 
         // force CURL adapter, this allows to handle correctly
         // compressed responses with SplObserver registered (kolab_files) (#4507)
         $http_config['adapter'] = 'HTTP_Request2_Adapter_Curl';
 
         $key = md5(serialize($http_config));
 
         if (!($request = self::$http_requests[$key])) {
             // load HTTP_Request2 (support both composer-installed and system-installed package)
             if (!class_exists('HTTP_Request2')) {
                 require_once 'HTTP/Request2.php';
             }
 
             try {
                 $request = new HTTP_Request2();
                 $request->setConfig($http_config);
             }
             catch (Exception $e) {
                 rcube::raise_error($e, true, true);
             }
 
             // proxy User-Agent string
             $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']);
 
             self::$http_requests[$key] = $request;
         }
 
         // cleanup
         try {
             $request->setBody('');
             $request->setUrl($url);
             $request->setMethod($method);
         }
         catch (Exception $e) {
             rcube::raise_error($e, true, true);
         }
 
         return $request;
     }
 
     /**
      * Table oultine for object changelog display
      */
     public static function object_changelog_table($attrib = array())
     {
         $rcube = rcube::get_instance();
         $attrib += array('domain' => 'libkolab');
 
         $table = new html_table(array('cols' => 5, 'border' => 0, 'cellspacing' => 0));
         $table->add_header('diff',      '');
         $table->add_header('revision',  $rcube->gettext('revision', $attrib['domain']));
         $table->add_header('date',      $rcube->gettext('date', $attrib['domain']));
         $table->add_header('user',      $rcube->gettext('user', $attrib['domain']));
         $table->add_header('operation', $rcube->gettext('operation', $attrib['domain']));
         $table->add_header('actions',   '&nbsp;');
 
         $rcube->output->add_label(
             'libkolab.showrevision',
             'libkolab.actionreceive',
             'libkolab.actionappend',
             'libkolab.actionmove',
             'libkolab.actiondelete',
             'libkolab.actionread',
             'libkolab.actionflagset',
             'libkolab.actionflagclear',
             'libkolab.objectchangelog',
             'libkolab.objectchangelognotavailable',
             'close'
         );
 
         return $table->show($attrib);
     }
 
     /**
      * Wrapper function for generating a html diff using the FineDiff class by Raymond Hill
      */
     public static function html_diff($from, $to, $is_html = null)
     {
         // auto-detect text/html format
         if ($is_html === null) {
             $from_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $from, $m) && strpos($from, '</'.$m[1].'>') > 0);
             $to_html   = (preg_match('/<(html|body)(\s+[a-z]|>)/', $to, $m) && strpos($to, '</'.$m[1].'>') > 0);
             $is_html   = $from_html || $to_html;
 
             // ensure both parts are of the same format
             if ($is_html && !$from_html) {
                 $converter = new rcube_text2html($from, false, array('wrap' => true));
                 $from = $converter->get_html();
             }
             if ($is_html && !$to_html) {
                 $converter = new rcube_text2html($to, false, array('wrap' => true));
                 $to = $converter->get_html();
             }
         }
 
         // compute diff from HTML
         if ($is_html) {
             include_once __dir__ . '/vendor/Caxy/HtmlDiff/Match.php';
             include_once __dir__ . '/vendor/Caxy/HtmlDiff/Operation.php';
             include_once __dir__ . '/vendor/Caxy/HtmlDiff/HtmlDiff.php';
 
             // replace data: urls with a transparent image to avoid memory problems
             $from = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $from);
             $to   = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $to);
 
             $diff = new Caxy\HtmlDiff\HtmlDiff($from, $to);
             $diffhtml = $diff->build();
 
             // remove empty inserts (from tables)
             return preg_replace('!<ins class="diff\w+">\s*</ins>!Uims', '', $diffhtml);
         }
         else {
             include_once __dir__ . '/vendor/finediff.php';
 
             $diff = new FineDiff($from, $to, FineDiff::$wordGranularity);
             return $diff->renderDiffToHTML();
         }
     }
 
     /**
      * Return a date() format string to render identifiers for recurrence instances
      *
      * @param array Hash array with event properties
      * @return string Format string
      */
     public static function recurrence_id_format($event)
     {
         return $event['allday'] ? 'Ymd' : 'Ymd\THis';
     }
 
     /**
      * Returns HTML code for folder search widget
      *
      * @param array $attrib Named parameters
      *
      * @return string HTML code for the gui object
      */
     public function folder_search_form($attrib)
     {
         $rcmail = rcube::get_instance();
         $attrib += array(
             'gui-object'    => false,
             'wrapper'       => true,
             'form-name'     => 'foldersearchform',
             'command'       => 'non-extsing-command',
             'reset-command' => 'non-existing-command',
         );
 
         if ($attrib['label-domain'] && !strpos($attrib['buttontitle'], '.')) {
             $attrib['buttontitle'] = $attrib['label-domain'] . '.' . $attrib['buttontitle'];
         }
 
         if ($attrib['buttontitle']) {
             $attrib['placeholder'] = $rcmail->gettext($attrib['buttontitle']);
         }
 
         return $rcmail->output->search_form($attrib);
     }
 }
diff --git a/plugins/libkolab/tests/KolabDateRecurrenceTest.php b/plugins/libkolab/tests/KolabDateRecurrenceTest.php
index 3c51aa19..c2ba0208 100644
--- a/plugins/libkolab/tests/KolabDateRecurrenceTest.php
+++ b/plugins/libkolab/tests/KolabDateRecurrenceTest.php
@@ -1,270 +1,342 @@
 <?php
 
 /**
  * kolab_date_recurrence tests
  *
  * @author Aleksander Machniak <machniak@kolabsys.com>
  *
  * Copyright (C) 2017, Kolab Systems AG <contact@kolabsys.com>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero 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 Affero General Public License for more details.
  *
  * You should have received a copy of the GNU Affero General Public License
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 
 class KolabDateRecurrenceTest extends PHPUnit\Framework\TestCase
 {
     function setUp(): void
     {
         $rcube = rcmail::get_instance();
         $rcube->plugins->load_plugin('libkolab', true, true);
+        $rcube->plugins->load_plugin('libcalendaring', true, true);
     }
 
     /**
-     * kolab_date_recurrence::first_occurrence()
-     *
-     * @dataProvider data_first_occurrence
+     * Data for test_end()
      */
-    function test_first_occurrence($recurrence_data, $start, $expected)
+    function data_end()
     {
-        if (!kolab_format::supports(3)) {
-            $this->markTestSkipped('No Kolab support');
-        }
-
-        $start = new DateTime($start);
-        if (!empty($recurrence_data['UNTIL'])) {
-            $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']);
-        }
-
-        $event  = array('start' => $start, 'recurrence' => $recurrence_data);
-        $object = kolab_format::factory('event', 3.0);
-        $object->set($event);
-
-        $recurrence = new kolab_date_recurrence($object);
-        $first      = $recurrence->first_occurrence();
+        return [
+            // non-recurring
+            [
+                [
+                    'recurrence' => [],
+                    'start' => new DateTime('2017-08-31 11:00:00')
+                ],
+                '2117-08-31 11:00:00',                             // expected result
+            ],
+            // daily
+            [
+                [
+                    'recurrence' => ['FREQ' => 'DAILY', 'INTERVAL' => '1', 'COUNT' => 2],
+                    'start' => new DateTime('2017-08-31 11:00:00')
+                ],
+                '2017-09-01 11:00:00',
+            ],
+            // weekly
+            [
+                [
+                    'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'COUNT' => 3],
+                    'start' => new DateTime('2017-08-31 11:00:00'), // Thursday
+                ],
+                '2017-09-14 11:00:00',
+            ],
+            // UNTIL
+            [
+                [
+                    'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'COUNT' => 3, 'UNTIL' => new DateTime('2017-09-07 11:00:00')],
+                    'start' => new DateTime('2017-08-31 11:00:00'), // Thursday
+                ],
+                '2017-09-07 11:00:00',
+            ],
+            // Infinite recurrence, no count, no until
+            [
+                [
+                    'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'],
+                    'start' => new DateTime('2017-08-31 11:00:00'), // Thursday
+                ],
+                '2117-08-31 11:00:00',
+            ],
 
-        $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : '');
+            // TODO: Test an event with EXDATE/RDATE
+        ];
     }
 
     /**
      * Data for test_first_occurrence()
      */
     function data_first_occurrence()
     {
         // TODO: BYYEARDAY, BYWEEKNO, BYSETPOS, WKST
 
         return array(
             // non-recurring
             array(
                 array(),                                     // recurrence data
                 '2017-08-31 11:00:00',                       // start date
                 '2017-08-31 11:00:00',                       // expected result
             ),
             // daily
             array(
                 array('FREQ' => 'DAILY', 'INTERVAL' => '1'), // recurrence data
                 '2017-08-31 11:00:00',                       // start date
                 '2017-08-31 11:00:00',                       // expected result
             ),
             // TODO: this one is not supported by the Calendar UI
             array(
                 array('FREQ' => 'DAILY', 'INTERVAL' => '1', 'BYMONTH' => 1),
                 '2017-08-31 11:00:00',
                 '2018-01-01 11:00:00',
             ),
             // weekly
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-06 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'TH'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'FR'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-01 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '2'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '3', 'BYDAY' => 'WE'),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-20 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'COUNT' => 1),
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-06 11:00:00',
             ),
             array(
                 array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'UNTIL' => '2017-09-01'),
                 '2017-08-31 11:00:00', // Thursday
                 '',
             ),
             // monthly
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1'),
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'),
                 '2017-08-31 11:00:00',
                 '2017-09-08 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'),
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '1WE'),
                 '2017-08-16 11:00:00',
                 '2017-09-06 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '-1WE'),
                 '2017-08-16 11:00:00',
                 '2017-08-30 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '2'),
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ),
             array(
                 array('FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'),
                 '2017-08-31 11:00:00',
                 '2017-09-08 11:00:00', // ??????
             ),
             // yearly
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1'),
                 '2017-08-16 12:00:00',
                 '2017-08-16 12:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8'),
                 '2017-08-16 12:00:00',
                 '2017-08-16 12:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'),
                 '2017-08-16 11:00:00',
                 '2017-12-25 11:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8', 'BYDAY' => '-1MO'),
                 '2017-08-16 11:00:00',
                 '2017-08-28 11:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'),
                 '2017-08-16 11:00:00',
                 '2018-01-01 11:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1,9', 'BYDAY' => '1MO'),
                 '2017-08-16 11:00:00',
                 '2017-09-04 11:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '2'),
                 '2017-08-16 11:00:00',
                 '2017-08-16 11:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYMONTH' => '8'),
                 '2017-08-16 11:00:00',
                 '2017-08-16 11:00:00',
             ),
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'),
                 '2017-08-16 11:00:00',
                 '2017-12-25 11:00:00',
             ),
             // on dates (FIXME: do we really expect the first occurrence to be on the start date?)
             array(
                 array('RDATE' => array (new DateTime('2017-08-10 11:00:00 Europe/Warsaw'))),
                 '2017-08-01 11:00:00',
                 '2017-08-01 11:00:00',
             ),
         );
     }
 
+    /**
+     * kolab_date_recurrence::end()
+     *
+     * @dataProvider data_end
+     */
+    function test_end($event, $expected)
+    {
+        if (!kolab_format::supports(3)) {
+            $this->markTestSkipped('No Kolab support');
+        }
+
+        $object = kolab_format::factory('event', 3.0);
+        $object->set($event);
+
+        $recurrence = new kolab_date_recurrence($object);
+        $end        = $recurrence->end();
+
+        $this->assertSame($expected, $end ? $end->format('Y-m-d H:i:s') : $end);
+    }
+
+    /**
+     * kolab_date_recurrence::first_occurrence()
+     *
+     * @dataProvider data_first_occurrence
+     */
+    function test_first_occurrence($recurrence_data, $start, $expected)
+    {
+        if (!kolab_format::supports(3)) {
+            $this->markTestSkipped('No Kolab support');
+        }
+
+        $start = new DateTime($start);
+        if (!empty($recurrence_data['UNTIL'])) {
+            $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']);
+        }
+
+        $event  = array('start' => $start, 'recurrence' => $recurrence_data);
+        $object = kolab_format::factory('event', 3.0);
+        $object->set($event);
+
+        $recurrence = new kolab_date_recurrence($object);
+        $first      = $recurrence->first_occurrence();
+
+        $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : '');
+    }
+
     /**
      * kolab_date_recurrence::first_occurrence() for all-day events
      *
      * @dataProvider data_first_occurrence
      */
     function test_first_occurrence_allday($recurrence_data, $start, $expected)
     {
         if (!kolab_format::supports(3)) {
             $this->markTestSkipped('No Kolab support');
         }
 
         $start = new DateTime($start);
         if (!empty($recurrence_data['UNTIL'])) {
             $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']);
         }
 
         $event  = array('start' => $start, 'recurrence' => $recurrence_data, 'allday' => true);
         $object = kolab_format::factory('event', 3.0);
         $object->set($event);
 
         $recurrence = new kolab_date_recurrence($object);
         $first      = $recurrence->first_occurrence();
 
         $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : '');
     }
 
     /**
      * kolab_date_recurrence::next_instance()
      */
     function test_next_instance()
     {
         if (!kolab_format::supports(3)) {
             $this->markTestSkipped('No Kolab support');
         }
 
         date_default_timezone_set('America/New_York');
 
         $start = new DateTime('2017-08-31 11:00:00', new DateTimeZone('Europe/Berlin'));
         $event = [
             'start'      => $start,
             'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'],
             'allday'     => true,
         ];
 
         $object = kolab_format::factory('event', 3.0);
         $object->set($event);
 
         $recurrence = new kolab_date_recurrence($object);
         $next       = $recurrence->next_instance();
 
         $this->assertEquals($start->format('2017-09-07 H:i:s'), $next['start']->format('Y-m-d H:i:s'), 'Same time');
         $this->assertEquals($start->getTimezone()->getName(), $next['start']->getTimezone()->getName(), 'Same timezone');
         $this->assertSame(true, $next['start']->_dateonly, '_dateonly flag');
     }
 }