diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php
index 0a347b7e..1dd2cea9 100644
--- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php
+++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php
@@ -1,358 +1,358 @@
 <?php
 
 /**
  * Kolab 2-Factor-Authentication Driver base class
  *
  * @author Thomas Bruederli <bruederli@kolabsys.com>
  *
  * Copyright (C) 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/>.
  */
 
 namespace Kolab2FA\Driver;
 
 /**
  * Kolab 2-Factor-Authentication Driver base class
  *
- * @property string $username 
+ * @property string $username
  * @property string $secret
  */
 abstract class Base
 {
     public $method;
     public $id;
     public $storage;
 
     protected $config          = [];
     protected $props           = [];
     protected $user_props      = [];
     protected $pending_changes = false;
     protected $temporary       = false;
     protected $allowed_props   = ['username'];
 
     public $user_settings = [
         'active' => [
             'type'     => 'boolean',
             'editable' => false,
             'hidden'   => false,
             'default'  => false,
         ],
         'label' => [
             'type'      => 'text',
             'editable'  => true,
             'label'     => 'label',
             'generator' => 'default_label',
         ],
         'created' => [
             'type'      => 'datetime',
             'editable'  => false,
             'hidden'    => false,
             'label'     => 'created',
             'generator' => 'time',
         ],
     ];
 
     /**
      * Static factory method
      */
     public static function factory($id, $config)
     {
         [$method] = explode(':', $id);
 
         $classmap = [
             'totp'    => '\\Kolab2FA\\Driver\\TOTP',
             'hotp'    => '\\Kolab2FA\\Driver\\HOTP',
             'yubikey' => '\\Kolab2FA\\Driver\\Yubikey',
         ];
 
         $cls = $classmap[strtolower($method)];
         if ($cls && class_exists($cls)) {
             return new $cls($config, $id);
         }
 
         throw new Exception("Unknown 2FA driver '$method'");
     }
 
     /**
      * Default constructor
      */
     public function __construct($config = null, $id = null)
     {
         $this->init($config);
 
         if (!empty($id) && $id != $this->method) {
             $this->id = $id;
         } else { // generate random ID
             $this->id = $this->method . ':' . bin2hex(openssl_random_pseudo_bytes(12));
             $this->temporary = true;
         }
     }
 
     /**
      * Initialize the driver with the given config options
      */
     public function init($config)
     {
         if (is_array($config)) {
             $this->config = array_merge($this->config, $config);
         }
 
         if (!empty($config['storage'])) {
             $this->storage = \Kolab2FA\Storage\Base::factory($config['storage'], $config['storage_config']);
         }
     }
 
     /**
      * Verify the submitted authentication code
      *
      * @param string $code      The 2nd authentication factor to verify
      * @param int    $timestamp Timestamp of authentication process (window start)
      *
      * @return bool True if valid, false otherwise
      */
     abstract public function verify($code, $timestamp = null);
 
     /**
      * Getter for user-visible properties
      */
     public function props($force = false)
     {
         $data = [];
 
         foreach ($this->user_settings as $key => $p) {
             if (!empty($p['private'])) {
                 continue;
             }
 
             $data[$key] = [
                 'type'     => $p['type'],
                 'editable' => $p['editable'] ?? false,
                 'hidden'   => $p['hidden'] ?? false,
                 'label'    => $p['label'] ?? '',
                 'value'    => $this->get($key, $force),
             ];
 
             // format value into text
             switch ($p['type']) {
                 case 'boolean':
                     $data[$key]['value'] = (bool)$data[$key]['value'];
                     $data[$key]['text'] = $data[$key]['value'] ? 'yes' : 'no';
                     break;
 
                 case 'datetime':
                     if (is_numeric($data[$key]['value'])) {
                         $data[$key]['text'] = date('c', $data[$key]['value']);
                         break;
                     }
 
                     // no break
                 default:
                     $data[$key]['text'] = $data[$key]['value'];
             }
         }
 
         return $data;
     }
 
     /**
      * Implement this method if the driver can be provisioned via QR code
      */
     /* abstract function get_provisioning_uri(); */
 
     /**
      * Generate a random secret string
      */
     public function generate_secret($length = 16)
     {
         // Base32 characters
         $chars = [
             'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', //  7
             'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
             'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
             'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
         ];
 
         $secret = '';
         for ($i = 0; $i < $length; $i++) {
             $secret .= $chars[array_rand($chars)];
         }
 
         return $secret;
     }
 
     /**
      * Generate the default label based on the method
      */
     public function default_label()
     {
         if (class_exists('\\rcmail', false)) {
             return \rcmail::get_instance()->gettext($this->method, 'kolab_2fa');
         }
 
         return strtoupper($this->method);
     }
 
     /**
      * Getter for read-only access to driver properties
      */
     public function get($key, $force = false)
     {
         // this is a per-user property: get from persistent storage
         if (isset($this->user_settings[$key])) {
             $value = $this->get_user_prop($key);
 
             // generate property value
             if (!isset($value) && $force && !empty($this->user_settings[$key]['generator'])) {
                 $func = $this->user_settings[$key]['generator'];
                 if (is_string($func) && !is_callable($func)) {
                     $func = [$this, $func];
                 }
                 if (is_callable($func)) {
                     $value = call_user_func($func);
                 }
                 if (isset($value)) {
                     $this->set_user_prop($key, $value);
                 }
             }
         } else {
             $value = $this->props[$key] ?? null;
         }
 
         return $value;
     }
 
     /**
      * Setter for restricted access to driver properties
      */
     public function set($key, $value, $persistent = true)
     {
         // store as per-user property
         if (isset($this->user_settings[$key])) {
             if ($persistent) {
                 return $this->set_user_prop($key, $value);
             }
             $this->user_props[$key] = $value;
         }
 
         $setter = 'set_' . $key;
         if (method_exists($this, $setter)) {
             call_user_func([$this, $setter], $value);
         } elseif (in_array($key, $this->allowed_props)) {
             $this->props[$key] = $value;
         }
 
         return true;
     }
 
     /**
      * Commit changes to storage
      */
     public function commit()
     {
         if (!empty($this->user_props) && $this->storage && $this->pending_changes) {
             if ($this->storage->write($this->id, $this->user_props)) {
                 $this->pending_changes = false;
                 $this->temporary = false;
             }
         }
 
         return !$this->pending_changes;
     }
 
     /**
      * Dedicated setter for the username property
      */
     public function set_username($username)
     {
         $this->props['username'] = $username;
 
         if ($this->storage) {
             $this->storage->set_username($username);
         }
 
         return true;
     }
 
     /**
      * Clear data stored for this driver
      */
     public function clear()
     {
         if ($this->storage) {
             return $this->storage->remove($this->id);
         }
 
         return false;
     }
 
     /**
      * Checks that a string contains a semicolon
      */
     protected function hasSemicolon($value)
     {
         return preg_match('/(:|%3A)/i', (string) $value) > 0;
     }
 
     /**
      * Getter for per-user properties for this method
      */
     protected function get_user_prop($key)
     {
         if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes && !$this->temporary) {
             $this->user_props = (array)$this->storage->read($this->id);
         }
 
         return $this->user_props[$key] ?? null;
     }
 
     /**
      * Setter for per-user properties for this method
      */
     protected function set_user_prop($key, $value)
     {
         $this->pending_changes |= (($this->user_props[$key] ?? null) !== $value);
         $this->user_props[$key] = $value;
         return true;
     }
 
     /**
      * Magic getter for read-only access to driver properties
      */
     public function __get($key)
     {
         // this is a per-user property: get from persistent storage
         if (isset($this->user_settings[$key])) {
             return $this->get_user_prop($key);
         }
 
         return $this->props[$key];
     }
 
     /**
      * Magic setter for restricted access to driver properties
      */
     public function __set($key, $value)
     {
         $this->set($key, $value, false);
     }
 
     /**
      * Magic check if driver property is defined
      */
     public function __isset($key)
     {
         return isset($this->props[$key]);
     }
 }
diff --git a/plugins/libcalendaring/lib/libcalendaring_recurrence.php b/plugins/libcalendaring/lib/libcalendaring_recurrence.php
index 3b06bd01..15bd77ea 100644
--- a/plugins/libcalendaring/lib/libcalendaring_recurrence.php
+++ b/plugins/libcalendaring/lib/libcalendaring_recurrence.php
@@ -1,296 +1,318 @@
 <?php
 
 use Sabre\VObject\Recur\EventIterator;
+use Sabre\VObject\Recur\NoInstancesException;
 
 /**
  * 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;
     protected $isStart = true;
 
     /**
      * Default constructor
      *
      * @param libcalendaring $lib   The libcalendaring plugin instance
      * @param array          $event The event object to operate on
      */
     public 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']);
             }
 
             $this->init($event['recurrence'], $event['start']);
         }
     }
 
     /**
      * Initialize recurrence engine
      *
      * @param array    $recurrence The recurrence properties
      * @param DateTime $start      The recurrence start date
      */
     public function init($recurrence, $start)
     {
         $this->start      = $start;
         $this->dateonly   = !empty($start->_dateonly) || !empty($this->event['allday']);
         $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);
+        try {
+            $this->engine = new EventIterator($ve, null, $this->lib->timezone);
+        } catch (NoInstancesException $e) {
+            // We treat a non-recurring event silently
+            // TODO: What about other exceptions?
+        }
     }
 
     /**
      * Get date/time of the next occurence of this event, and push the iterator.
      *
-     * @return DateTime|false object or False if recurrence ended
+     * @return libcalendaring_datetime|false object or False if recurrence ended
      */
     public function next_start()
     {
+        if (empty($this->engine)) {
+            return false;
+        }
+
         try {
             $this->engine->next();
             $current = $this->engine->getDtStart();
         } catch (Exception $e) {
             // do nothing
         }
 
         return !empty($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 (empty($this->engine)) {
+            return false;
+        }
+
         // Here's the workaround for an issue for an event with its start date excluded
         // E.g. A daily event starting on 10th which is one of EXDATE dates
         // should return 11th as next_instance() when called for the first time.
         // Looks like Sabre is setting internal "current date" to 11th on such an object
         // initialization, therefore calling next() would move it to 12th.
         if ($this->isStart && ($next_start = $this->engine->getDtStart())
             && $next_start->format('Ymd') != $this->start->format('Ymd')
         ) {
             $next_start = $this->toDateTime($next_start);
         } else {
             $next_start = $this->next_start();
         }
 
         $this->isStart = false;
 
         if ($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);
 
             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
+     * @return libcalendaring_datetime|false End datetime of the last occurrence or False if there's no end date
      */
     public function end()
     {
+        if (empty($this->engine)) {
+            return $this->toDateTime($this->start);
+        }
+
         // recurrence end date is given
         if (isset($this->recurrence['UNTIL']) && $this->recurrence['UNTIL'] instanceof DateTimeInterface) {
             return $this->toDateTime($this->recurrence['UNTIL']);
         }
 
         // 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
             }
         } 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['start'];
             $end->add(new DateInterval('P100Y'));
         }
         */
 
         return isset($end) ? $this->toDateTime($end) : false;
     }
 
     /**
      * Find date/time of the first occurrence (excluding start date)
      *
-     * @return DateTime|null First occurrence
+     * @return libcalendaring_datetime|null First occurrence
      */
     public function first_occurrence()
     {
+        if (empty($this->engine)) {
+            return $this->toDateTime($this->start);
+        }
+
         $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;
                 }
 
                 // no 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 f8e3b285..f2dc3c16 100644
--- a/plugins/libcalendaring/tests/RecurrenceTest.php
+++ b/plugins/libcalendaring/tests/RecurrenceTest.php
@@ -1,433 +1,473 @@
 <?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;
 
     public function setUp(): void
     {
         $rcube = rcmail::get_instance();
         $rcube->plugins->load_plugin('libcalendaring', true, true);
 
         $this->plugin = $rcube->plugins->get_plugin('libcalendaring');
     }
 
     /**
      * Data for test_end()
      */
     public function data_end()
     {
         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',
             ],
 
             // TODO: Test an event with EXDATE/RDATEs
         ];
     }
     /**
      * Data for test_first_occurrence()
      */
     public function data_first_occurrence()
     {
         // TODO: BYYEARDAY, BYWEEKNO, BYSETPOS, WKST
 
         return [
             // non-recurring
             [
                 [],                                     // recurrence data
                 '2017-08-31 11:00:00',                       // start date
                 '2017-08-31 11:00:00',                       // expected result
             ],
             // daily
             [
                 ['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
             [
                 ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'],
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ],
             [
                 ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE'],
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-06 11:00:00',
             ],
             [
                 ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'TH'],
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ],
             [
                 ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'FR'],
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-01 11:00:00',
             ],
             [
                 ['FREQ' => 'WEEKLY', 'INTERVAL' => '2'],
                 '2017-08-31 11:00:00', // Thursday
                 '2017-08-31 11:00:00',
             ],
             [
                 ['FREQ' => 'WEEKLY', 'INTERVAL' => '3', 'BYDAY' => 'WE'],
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-20 11:00:00',
             ],
             [
                 ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'COUNT' => 1],
                 '2017-08-31 11:00:00', // Thursday
                 '2017-09-06 11:00:00',
             ],
             [
                 ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'UNTIL' => '2017-09-01'],
                 '2017-08-31 11:00:00', // Thursday
                 '',
             ],
             // monthly
             [
                 ['FREQ' => 'MONTHLY', 'INTERVAL' => '1'],
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ],
             [
                 ['FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'],
                 '2017-08-31 11:00:00',
                 '2017-09-08 11:00:00',
             ],
             [
                 ['FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'],
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ],
             [
                 ['FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '1WE'],
                 '2017-08-16 11:00:00',
                 '2017-09-06 11:00:00',
             ],
             [
                 ['FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '-1WE'],
                 '2017-08-16 11:00:00',
                 '2017-08-30 11:00:00',
             ],
             [
                 ['FREQ' => 'MONTHLY', 'INTERVAL' => '2'],
                 '2017-09-08 11:00:00',
                 '2017-09-08 11:00:00',
             ],
             [
                 ['FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'],
                 '2017-08-31 11:00:00',
                 '2017-09-08 11:00:00', // ??????
             ],
             // yearly
             [
                 ['FREQ' => 'YEARLY', 'INTERVAL' => '1'],
                 '2017-08-16 12:00:00',
                 '2017-08-16 12:00:00',
             ],
             [
                 ['FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8'],
                 '2017-08-16 12:00:00',
                 '2017-08-16 12:00:00',
             ],
 /*
             // Not supported by Sabre (requires BYMONTH too)
             array(
                 array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'),
                 '2017-08-16 11:00:00',
                 '2017-12-25 11:00:00',
             ),
 */
             [
                 ['FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8', 'BYDAY' => '-1MO'],
                 '2017-08-16 11:00:00',
                 '2017-08-28 11:00:00',
             ],
             [
                 ['FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'],
                 '2017-08-16 11:00:00',
                 '2018-01-01 11:00:00',
             ],
             [
                 ['FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1,9', 'BYDAY' => '1MO'],
                 '2017-08-16 11:00:00',
                 '2017-09-04 11:00:00',
             ],
             [
                 ['FREQ' => 'YEARLY', 'INTERVAL' => '2'],
                 '2017-08-16 11:00:00',
                 '2017-08-16 11:00:00',
             ],
             [
                 ['FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYMONTH' => '8'],
                 '2017-08-16 11:00:00',
                 '2017-08-16 11:00:00',
             ],
 /*
             // Not supported by Sabre (requires BYMONTH too)
             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?)
             [
                 ['RDATE' => [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
      */
     public 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
      */
     public 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
      */
     public 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 an event with invalid recurrence
+     */
+    public function test_invalid_recurrence_event()
+    {
+        date_default_timezone_set('Europe/Berlin');
+
+        // This is an event with no RRULE, but one RDATE, however the RDATE is cancelled by EXDATE.
+        // This normally causes Sabre\VObject\Recur\NoInstancesException. We make sure it does not happen.
+        // The same will probably happen on any event without recurrence passed to libcalendring_vcalendar.
+
+        $vcal = <<<EOF
+            BEGIN:VCALENDAR
+            VERSION:2.0
+            PRODID:-//Apple Inc.//iCal 5.0.3//EN
+            CALSCALE:GREGORIAN
+            BEGIN:VEVENT
+            UID:fb1cb690-b963-4ea5-b58f-ac9773e36d9a
+            DTSTART;TZID=Europe/Berlin:20210604T093000
+            DTEND;TZID=Europe/Berlin:20210606T093000
+            RDATE:20210604T073000Z
+            EXDATE;TZID=Europe/Berlin:20210604T093000
+            DTSTAMP:20210528T091628Z
+            LAST-MODIFIED:20210528T091628Z
+            CREATED:20210528T091213Z
+            END:VEVENT
+            END:VCALENDAR
+            EOF;
+
+        $ical = new libcalendaring_vcalendar();
+        $event = $ical->import($vcal)[0];
+
+        $recurrence = new libcalendaring_recurrence($this->plugin, $event);
+
+        $this->assertSame($event['start']->format('Y-m-d H:i:s'), $recurrence->end()->format('Y-m-d H:i:s'));
+        $this->assertSame($event['start']->format('Y-m-d H:i:s'), $recurrence->first_occurrence()->format('Y-m-d H:i:s'));
+        $this->assertFalse($recurrence->next_start());
+        $this->assertFalse($recurrence->next_instance());
+    }
+
     /**
      * Test for libcalendaring_recurrence::next_instance()
      */
     public 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');
     }
 
     /**
      * Test for libcalendaring_recurrence::next_instance()
      */
     public function test_next_instance_exdate()
     {
         date_default_timezone_set('America/New_York');
 
         $start = new libcalendaring_datetime('2023-01-18 10:00:00', new DateTimeZone('Europe/Berlin'));
         $end = new libcalendaring_datetime('2023-01-18 10:30:00', new DateTimeZone('Europe/Berlin'));
         $event = [
             'start' => $start,
             'end' => $end,
             'recurrence' => [
                 'FREQ' => 'DAILY',
                 'INTERVAL' => '1',
                 'EXDATE' => [
                     // Exclude the start date
                     new libcalendaring_datetime('2023-01-18 10:00:00', new DateTimeZone('Europe/Berlin')),
                 ],
             ],
         ];
 
         $recurrence = new libcalendaring_recurrence($this->plugin, $event);
 
         $next = $recurrence->next_instance();
 
         $this->assertEquals('2023-01-19 10:00:00', $next['start']->format('Y-m-d H:i:s'));
         $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
         $this->assertFalse($next['start']->_dateonly);
 
         $next = $recurrence->next_instance();
 
         $this->assertEquals('2023-01-20 10:00:00', $next['start']->format('Y-m-d H:i:s'));
         $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
         $this->assertFalse($next['start']->_dateonly);
     }
 
     /**
      * Test for libcalendaring_recurrence::next_instance()
      */
     public function test_next_instance_dst()
     {
         date_default_timezone_set('America/New_York');
 
         $start = new libcalendaring_datetime('2021-03-10 10:00:00', new DateTimeZone('Europe/Berlin'));
         $end = new libcalendaring_datetime('2021-03-10 10:30:00', new DateTimeZone('Europe/Berlin'));
         $event = [
             'start' => $start,
             'end' => $end,
             'recurrence' => [
                 'FREQ' => 'MONTHLY',
                 'INTERVAL' => '1',
             ],
         ];
 
         $recurrence = new libcalendaring_recurrence($this->plugin, $event);
 
         $next = $recurrence->next_instance();
 
         $this->assertEquals('2021-04-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
         $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
 
         $next = $recurrence->next_instance();
 
         $this->assertEquals('2021-05-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
         $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
 
         $start = new libcalendaring_datetime('2021-10-10 10:00:00', new DateTimeZone('Europe/Berlin'));
         $end = new libcalendaring_datetime('2021-10-10 10:30:00', new DateTimeZone('Europe/Berlin'));
         $event = [
             'start' => $start,
             'end' => $end,
             'recurrence' => [
                 'FREQ' => 'MONTHLY',
                 'INTERVAL' => '1',
             ],
         ];
 
         $recurrence = new libcalendaring_recurrence($this->plugin, $event);
 
         $next = $recurrence->next_instance();
 
         $this->assertEquals('2021-11-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
         $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
 
         $next = $recurrence->next_instance();
 
         $this->assertEquals('2021-12-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
         $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
 
         $next = $recurrence->next_instance();
         $next = $recurrence->next_instance();
         $next = $recurrence->next_instance();
         $next = $recurrence->next_instance();
 
         $this->assertEquals('2022-04-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
         $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
 
     }
 }
diff --git a/plugins/libkolab/lib/kolab_storage_cache_event.php b/plugins/libkolab/lib/kolab_storage_cache_event.php
index 8a4028f2..a7a845c7 100644
--- a/plugins/libkolab/lib/kolab_storage_cache_event.php
+++ b/plugins/libkolab/lib/kolab_storage_cache_event.php
@@ -1,68 +1,69 @@
 <?php
 
 /**
  * Kolab storage cache class for calendar event objects
  *
  * @author Thomas Bruederli <bruederli@kolabsys.com>
  *
  * Copyright (C) 2013, 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_storage_cache_event extends kolab_storage_cache
 {
     protected $extra_cols = ['dtstart','dtend'];
     protected $data_props = ['categories', 'status', 'attendees']; // start, end
 
     /**
      * Helper method to convert the given Kolab object into a dataset to be written to cache
      *
      * @override
      */
     protected function _serialize($object)
     {
         $sql_data = parent::_serialize($object);
 
         $sql_data['dtstart'] = $this->_convert_datetime($object['start'] ?? null);
         $sql_data['dtend']   = $this->_convert_datetime($object['end'] ?? null);
 
         // extend date range for recurring events
         if (!empty($object['recurrence'])) {
             $recurrence = new kolab_date_recurrence($object['_formatobj']);
-            $dtend = $recurrence->end() ?: new DateTime('now +100 years');
-            $sql_data['dtend'] = $this->_convert_datetime($dtend);
+            if ($dtend = $recurrence->end()) {
+                $sql_data['dtend'] = $this->_convert_datetime($dtend);
+            }
         }
 
         // extend start/end dates to spawn all exceptions
         if (!empty($object['exceptions'])) {
             foreach ($object['exceptions'] as $exception) {
                 if (($exception['start'] ?? null) instanceof DateTimeInterface) {
                     $exstart = $this->_convert_datetime($exception['start']);
                     if ($exstart < $sql_data['dtstart']) {
                         $sql_data['dtstart'] = $exstart;
                     }
                 }
                 if (($exception['end'] ?? null) instanceof DateTimeInterface) {
                     $exend = $this->_convert_datetime($exception['end']);
                     if ($exend > $sql_data['dtend']) {
                         $sql_data['dtend'] = $exend;
                     }
                 }
             }
         }
 
         return $sql_data;
     }
 }
diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_event.php b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php
index 0f31b6e0..053c1573 100644
--- a/plugins/libkolab/lib/kolab_storage_dav_cache_event.php
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php
@@ -1,154 +1,155 @@
 <?php
 
 /**
  * Kolab storage cache class for calendar event objects
  *
  * @author Aleksander Machniak <machniak@apheleia-it.ch>
  *
  * Copyright (C) 2013-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 kolab_storage_dav_cache_event extends kolab_storage_dav_cache
 {
     protected $extra_cols = ['dtstart','dtend'];
     protected $data_props = ['categories', 'status', 'attendees'];
     protected $fulltext_cols = ['title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories'];
 
     /**
      * Helper method to convert the given Kolab object into a dataset to be written to cache
      *
      * @override
      */
     protected function _serialize($object)
     {
         $sql_data = parent::_serialize($object);
 
         $sql_data['dtstart'] = $this->_convert_datetime($object['start']);
         $sql_data['dtend']   = $this->_convert_datetime($object['end']);
 
         // extend date range for recurring events
         if (!empty($object['recurrence'])) {
             $recurrence = libcalendaring::get_recurrence($object);
-            $dtend = $recurrence->end() ?: new DateTime('now +100 years');
-            $sql_data['dtend'] = $this->_convert_datetime($dtend);
+            if ($dtend = $recurrence->end()) {
+                $sql_data['dtend'] = $this->_convert_datetime($dtend);
+            }
         }
 
         // extend start/end dates to spawn all exceptions
         // FIXME: This should be done via libcalendaring_recurrence use above?
         if (!empty($object['exceptions']) && is_array($object['exceptions'])) {
             foreach ($object['exceptions'] as $exception) {
                 if ($exception['start'] instanceof DateTimeInterface) {
                     $exstart = $this->_convert_datetime($exception['start']);
                     if ($exstart < $sql_data['dtstart']) {
                         $sql_data['dtstart'] = $exstart;
                     }
                 }
                 if ($exception['end'] instanceof DateTimeInterface) {
                     $exend = $this->_convert_datetime($exception['end']);
                     if ($exend > $sql_data['dtend']) {
                         $sql_data['dtend'] = $exend;
                     }
                 }
             }
         }
 
         $sql_data['tags']  = ' ' . implode(' ', $this->get_tags($object)) . ' ';  // pad with spaces for strict/prefix search
         $sql_data['words'] = ' ' . implode(' ', $this->get_words($object)) . ' ';
 
         return $sql_data;
     }
 
     /**
      * Callback to get words to index for fulltext search
      *
      * @return array List of words to save in cache
      */
     public function get_words($object = [])
     {
         $data = '';
 
         foreach ($this->fulltext_cols as $colname) {
             [$col, $field] = strpos($colname, ':') ? explode(':', $colname) : [$colname, null];
 
             if (empty($object[$col])) {
                 continue;
             }
 
             if ($field) {
                 $a = [];
                 foreach ((array) $object[$col] as $attr) {
                     if (!empty($attr[$field])) {
                         $a[] = $attr[$field];
                     }
                 }
                 $val = implode(' ', $a);
             } else {
                 $val = is_array($object[$col]) ? implode(' ', $object[$col]) : $object[$col];
             }
 
             if (is_string($val) && strlen($val)) {
                 $data .= $val . ' ';
             }
         }
 
         $words = rcube_utils::normalize_string($data, true);
 
         // collect words from recurrence exceptions
         if (!empty($object['exceptions']) && is_array($object['exceptions'])) {
             foreach ($object['exceptions'] as $exception) {
                 $words = array_merge($words, $this->get_words($exception));
             }
         }
 
         return array_unique($words);
     }
 
     /**
      * Callback to get object specific tags to cache
      *
      * @return array List of tags to save in cache
      */
     public function get_tags($object)
     {
         $tags = [];
 
         if (!empty($object['valarms'])) {
             $tags[] = 'x-has-alarms';
         }
 
         // create tags reflecting participant status
         if (is_array($object['attendees'])) {
             foreach ($object['attendees'] as $attendee) {
                 if (!empty($attendee['email']) && !empty($attendee['status'])) {
                     $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']);
                 }
             }
         }
 
         // collect tags from recurrence exceptions
         if (!empty($object['exceptions']) && is_array($object['exceptions'])) {
             foreach ($object['exceptions'] as $exception) {
                 $tags = array_merge($tags, $this->get_tags($exception));
             }
         }
 
         if (!empty($object['status'])) {
             $tags[] = 'x-status:' . strtolower($object['status']);
         }
 
         return array_unique($tags);
     }
 }