From c215370f14e32b005e1885c878d74fc22ae3cd7e Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Fri, 19 Jun 2026 11:24:00 +0200 Subject: [PATCH 1/5] feat(imp): wire EAS 16.1 counter-proposal accept/decline in iTip UI Use Kronolith acceptCounterProposal and declineCounterProposal registry APIs from IMP webmail when the organizer accepts or declines a METHOD=COUNTER message. Clear proposals on local attendee calendars server-side before sending DECLINECOUNTER mail so ActiveSync can remove the proposal on iOS without manual mail handling. - counter-accept: calendar/acceptCounterProposal (replaces updateAttendee) - counter-decline: decline on organizer calendar + notify attendee - decline-counter: attendee clears own proposal from calendar - _sendDeclineCounter: applyDeclineCounterToLocalUser before mail send - Itip viewer: DECLINECOUNTER auto-update and REQUEST auto-replace - ItipRequestCounterTest: assert new registry call paths --- lib/Ajax/Imple/ItipRequest.php | 33 +++++++++++-- lib/Mime/Viewer/Itip.php | 47 ++++++++++++++++++- .../Ajax/Imple/ItipRequestCounterTest.php | 26 +++++++--- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/lib/Ajax/Imple/ItipRequest.php b/lib/Ajax/Imple/ItipRequest.php index 15e8a3f66..fd7568204 100644 --- a/lib/Ajax/Imple/ItipRequest.php +++ b/lib/Ajax/Imple/ItipRequest.php @@ -183,17 +183,17 @@ protected function _handle(Variables|Horde_Variables $vars) case 'counter-accept': if (isset($components[$key]) && $components[$key]->getType() == 'vEvent') { $result = $this->_handlevEvent($key, $components, $mime_part); - if ($result && $registry->hasMethod('calendar/updateAttendee')) { + if ($result && $registry->hasMethod('calendar/acceptCounterProposal')) { try { if ($tmp = $contents->getHeader()->getHeader('from')) { - $registry->call('calendar/updateAttendee', [ + $registry->call('calendar/acceptCounterProposal', [ $components[$key], $tmp->getAddressList(true)->first()->bare_address, - true, ]); } } catch (Horde_Exception $e) { - $notification->push(sprintf(_('There was an error updating the event attendee state: %s'), $e->getMessage()), 'horde.warning'); + Horde::log($e, Horde_Log::ERR); + $notification->push(sprintf(_('There was an error notifying attendees of the accepted proposal: %s'), $e->getMessage()), 'horde.warning'); } } } else { @@ -211,6 +211,13 @@ protected function _handle(Variables|Horde_Variables $vars) if (empty($to)) { throw new Horde_Exception(_("Unable to determine attendee address.")); } + if ($registry->hasMethod('calendar/declineCounterProposal')) { + $registry->call('calendar/declineCounterProposal', [ + $components[$key], + $to, + true, + ]); + } $this->_sendDeclineCounter($components[$key], $to, $vars->identity); $notification->push(_('Decline counter sent.'), 'horde.success'); $result = true; @@ -223,6 +230,22 @@ protected function _handle(Variables|Horde_Variables $vars) } break; + case 'decline-counter': + if (isset($components[$key]) && $components[$key]->getType() == 'vEvent' + && $registry->hasMethod('calendar/declineCounterProposal')) { + try { + $registry->call('calendar/declineCounterProposal', [$components[$key]]); + $notification->push(_('The proposed new time was removed from your calendar.'), 'horde.success'); + $result = true; + } catch (Horde_Exception $e) { + Horde::log($e, Horde_Log::ERR); + $notification->push(sprintf(_('There was an error updating the event: %s'), $e->getMessage()), 'horde.error'); + } + } else { + $notification->push(_('This action is not supported.'), 'horde.warning'); + } + break; + case 'import': case 'accept-import': // vFreebusy reply. @@ -568,6 +591,8 @@ protected function _sendDeclineCounter( } $headers->addHeader('Subject', _('Decline Counter Proposal')); + Kronolith::applyDeclineCounterToLocalUser($toAddress, $vevent); + $mime->send($toAddress, $headers, $injector->getInstance('IMP_Mail')); } diff --git a/lib/Mime/Viewer/Itip.php b/lib/Mime/Viewer/Itip.php index 9e2599a8f..0351133a4 100644 --- a/lib/Mime/Viewer/Itip.php +++ b/lib/Mime/Viewer/Itip.php @@ -29,6 +29,7 @@ class IMP_Mime_Viewer_Itip extends Horde_Mime_Viewer_Base { public const AUTO_UPDATE_EVENT_REPLY = 'auto_update_eventreply'; + public const AUTO_UPDATE_EVENT_REQUEST = 'auto_update_eventrequest'; public const AUTO_UPDATE_FB_PUBLISH = 'auto_update_fbpublish'; public const AUTO_UPDATE_FB_REPLY = 'auto_update_fbreply'; public const AUTO_UPDATE_TASK_REPLY = 'auto_update_taskreply'; @@ -333,7 +334,34 @@ protected function _vEvent($vevent, $id, $method = 'PUBLISH', $components = []) } } - if ($is_update && $registry->hasMethod('calendar/replace')) { + $auto_updated = false; + if ($is_update + && $registry->hasMethod('calendar/replace') + && $this->_autoUpdateReply(self::AUTO_UPDATE_EVENT_REQUEST, $this->_senderFromHeader())) { + try { + $uid = $vevent->getAttributeSingle('UID'); + $registry->call('calendar/replace', [ + $uid, + $vevent, + 'text/calendar', + ]); + $url = Horde::url($registry->link('calendar/show', ['uid' => $uid])); + $notification->push( + _('The event was updated in your calendar.') . ' ' + . Horde::link($url, _('View event'), null, '_blank') + . Horde_Themes_Image::tag('mime/icalendar.png', ['alt' => _('View event')]) + . '', + 'horde.success', + ['content.raw'] + ); + $auto_updated = true; + } catch (Horde_Exception $e) { + Horde::log($e, Horde_Log::ERR); + $notification->push(sprintf(_('There was an error updating the event: %s'), $e->getMessage()), 'horde.error'); + } + } + + if ($is_update && !$auto_updated && $registry->hasMethod('calendar/replace')) { $options['accept-import'] = _('Accept and update in my calendar'); $options['import'] = _('Update in my calendar'); } elseif ($registry->hasMethod('calendar/import')) { @@ -404,6 +432,23 @@ protected function _vEvent($vevent, $id, $method = 'PUBLISH', $components = []) } break; + case 'DECLINECOUNTER': + $desc = _('%s has declined your proposed new time for "%s".'); + $sender = $this->_senderFromHeader(); + if ($registry->hasMethod('calendar/declineCounterProposal') + && $this->_autoUpdateReply(self::AUTO_UPDATE_EVENT_REQUEST, $sender)) { + try { + $registry->call('calendar/declineCounterProposal', [$vevent]); + $notification->push(_('The proposed new time was removed from your calendar.'), 'horde.success'); + } catch (Horde_Exception $e) { + Horde::log($e, Horde_Log::ERR); + $notification->push(sprintf(_('There was an error updating the event: %s'), $e->getMessage()), 'horde.error'); + } + } elseif ($registry->hasMethod('calendar/declineCounterProposal')) { + $options['decline-counter'] = _('Remove proposed new time from my calendar'); + } + break; + case 'CANCEL': try { $vevent->getAttributeSingle('RECURRENCE-ID'); diff --git a/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php b/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php index 83368e837..e1b08e11c 100644 --- a/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php +++ b/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php @@ -207,6 +207,8 @@ public function _registryRemoteHost() public function _registryHasMethod($method) { return in_array($method, [ + 'calendar/acceptCounterProposal', + 'calendar/declineCounterProposal', 'calendar/updateAttendee', 'calendar/export', 'calendar/replace', @@ -287,22 +289,32 @@ public function testCounterDeclineSendsDeclineCounterMessage() 'counter.attendee@example.com', $this->_getMailHeaders()->getValue('To') ); + + $declineCalls = array_filter( + $this->_registryCalls, + function ($call) { + return $call[0] === 'calendar/declineCounterProposal'; + } + ); + $this->assertCount(1, $declineCalls); + $declineCall = reset($declineCalls); + $this->assertSame('counter.attendee@example.com', $declineCall[1][1]); + $this->assertTrue($declineCall[1][2]); } - public function testCounterAcceptStoresProposalAfterAcceptingEvent() + public function testCounterAcceptNotifiesAttendeesAfterAcceptingEvent() { $this->_doRequest('counter-accept', $this->_getCounterCalendar(), 'default', true); - $updateCalls = array_filter( + $acceptCalls = array_filter( $this->_registryCalls, function ($call) { - return $call[0] === 'calendar/updateAttendee'; + return $call[0] === 'calendar/acceptCounterProposal'; } ); - $this->assertCount(1, $updateCalls); - $updateCall = reset($updateCalls); - $this->assertTrue($updateCall[1][2]); - $this->assertSame('counter.attendee@example.com', $updateCall[1][1]); + $this->assertCount(1, $acceptCalls); + $acceptCall = reset($acceptCalls); + $this->assertSame('counter.attendee@example.com', $acceptCall[1][1]); } public function testCounterUpdateStoresProposalForCounterMethod() From fa89df41c3789c211237c4ab89540df6f92e4e9f Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Fri, 19 Jun 2026 11:49:31 +0200 Subject: [PATCH 2/5] Update mime_drivers.php --- config/mime_drivers.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config/mime_drivers.php b/config/mime_drivers.php index 5cd5ff196..8281fa824 100644 --- a/config/mime_drivers.php +++ b/config/mime_drivers.php @@ -246,6 +246,16 @@ * reply status to be explicitly updated by user action. */ 'auto_update_eventreply' => false, + /* How event updates (METHOD=REQUEST for an existing event) are + * handled when a user opens the message. + * - false: The calendar is never automatically updated; requires + * explicit action by the user. + * - true: The calendar is always automatically updated. + * - Array: An array of domains for which updates are always + * automatically applied. All other domains require the + * user to explicitly update the calendar. */ + 'auto_update_eventrequest' => true, + /* How free/busy publish data is handled when a user opens the * message. * - false: Free/busy data is never automatically updated; requires From ea59c7d2a983034140793255d76066e394e8d787 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Fri, 19 Jun 2026 13:54:56 +0200 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Ajax/Imple/ItipRequest.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/Ajax/Imple/ItipRequest.php b/lib/Ajax/Imple/ItipRequest.php index fd7568204..427bec6a7 100644 --- a/lib/Ajax/Imple/ItipRequest.php +++ b/lib/Ajax/Imple/ItipRequest.php @@ -212,11 +212,16 @@ protected function _handle(Variables|Horde_Variables $vars) throw new Horde_Exception(_("Unable to determine attendee address.")); } if ($registry->hasMethod('calendar/declineCounterProposal')) { - $registry->call('calendar/declineCounterProposal', [ - $components[$key], - $to, - true, - ]); + try { + $registry->call('calendar/declineCounterProposal', [ + $components[$key], + $to, + true, + ]); + } catch (Horde_Exception $e) { + Horde::log($e, Horde_Log::ERR); + $notification->push(sprintf(_('There was an error clearing the proposed new time: %s'), $e->getMessage()), 'horde.warning'); + } } $this->_sendDeclineCounter($components[$key], $to, $vars->identity); $notification->push(_('Decline counter sent.'), 'horde.success'); From 03c3612d16de60097776a2a0dde32d415c38cf76 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Fri, 19 Jun 2026 13:56:10 +0200 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Ajax/Imple/ItipRequest.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Ajax/Imple/ItipRequest.php b/lib/Ajax/Imple/ItipRequest.php index 427bec6a7..7e0f6c435 100644 --- a/lib/Ajax/Imple/ItipRequest.php +++ b/lib/Ajax/Imple/ItipRequest.php @@ -596,7 +596,13 @@ protected function _sendDeclineCounter( } $headers->addHeader('Subject', _('Decline Counter Proposal')); - Kronolith::applyDeclineCounterToLocalUser($toAddress, $vevent); + if (class_exists('Kronolith') && method_exists('Kronolith', 'applyDeclineCounterToLocalUser')) { + try { + Kronolith::applyDeclineCounterToLocalUser($toAddress, $vevent); + } catch (Horde_Exception $e) { + Horde::log($e, Horde_Log::ERR); + } + } $mime->send($toAddress, $headers, $injector->getInstance('IMP_Mail')); } From b8916e3eecb3c21223d71c836db0fa9a22b89105 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:58:55 +0000 Subject: [PATCH 5/5] test: rename misleading counter-accept test --- test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php b/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php index e1b08e11c..6d0fba596 100644 --- a/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php +++ b/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php @@ -302,7 +302,7 @@ function ($call) { $this->assertTrue($declineCall[1][2]); } - public function testCounterAcceptNotifiesAttendeesAfterAcceptingEvent() + public function testCounterAcceptPassesAttendeeEmailToAcceptCounterProposal() { $this->_doRequest('counter-accept', $this->_getCounterCalendar(), 'default', true);