diff --git a/lib/Ajax/Imple/ItipRequest.php b/lib/Ajax/Imple/ItipRequest.php index 50dc7775a..15e8a3f66 100644 --- a/lib/Ajax/Imple/ItipRequest.php +++ b/lib/Ajax/Imple/ItipRequest.php @@ -12,6 +12,7 @@ * @package IMP */ +use Horde\Util\HordeString; use Horde\Util\Variables; /** @@ -126,18 +127,29 @@ protected function _handle(Variables|Horde_Variables $vars) break; case 'update': - // vEvent reply. + // vEvent reply or counter-proposal. // vTodo reply. switch ($components[$key]->getType()) { case 'vEvent': if ($registry->hasMethod('calendar/updateAttendee')) { try { if ($tmp = $contents->getHeader()->getHeader('from')) { + $storeProposal = false; + try { + $storeProposal = strtoupper($vCal->getAttribute('METHOD')) === 'COUNTER'; + } catch (Horde_Icalendar_Exception $e) { + } $registry->call('calendar/updateAttendee', [ $components[$key], $tmp->getAddressList(true)->first()->bare_address, + $storeProposal, ]); - $notification->push(_('Respondent Status Updated.'), 'horde.success'); + $notification->push( + $storeProposal + ? _('Counter proposal recorded.') + : _('Respondent Status Updated.'), + 'horde.success' + ); $result = true; } } catch (Horde_Exception $e) { @@ -168,6 +180,49 @@ protected function _handle(Variables|Horde_Variables $vars) } break; + case 'counter-accept': + if (isset($components[$key]) && $components[$key]->getType() == 'vEvent') { + $result = $this->_handlevEvent($key, $components, $mime_part); + if ($result && $registry->hasMethod('calendar/updateAttendee')) { + try { + if ($tmp = $contents->getHeader()->getHeader('from')) { + $registry->call('calendar/updateAttendee', [ + $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'); + } + } + } else { + $notification->push(_('This action is not supported.'), 'horde.warning'); + } + break; + + case 'counter-decline': + if (isset($components[$key]) && $components[$key]->getType() == 'vEvent') { + try { + $to = null; + if ($tmp = $contents->getHeader()->getHeader('from')) { + $to = $tmp->getAddressList(true)->first()->bare_address; + } + if (empty($to)) { + throw new Horde_Exception(_("Unable to determine attendee address.")); + } + $this->_sendDeclineCounter($components[$key], $to, $vars->identity); + $notification->push(_('Decline counter sent.'), 'horde.success'); + $result = true; + } catch (Horde_Exception $e) { + Horde::log($e, Horde_Log::ERR); + $notification->push(sprintf(_('Error sending decline counter: %s.'), $e->getMessage()), 'horde.error'); + } + } else { + $notification->push(_('This action is not supported.'), 'horde.warning'); + } + break; + case 'import': case 'accept-import': // vFreebusy reply. @@ -463,6 +518,59 @@ protected function _handle(Variables|Horde_Variables $vars) return $result; } + /** + * Send a METHOD=DECLINECOUNTER response to an attendee. + */ + protected function _sendDeclineCounter( + Horde_Icalendar_Vevent $vevent, + $toAddress, + $identityId = null + ) { + global $injector; + + $identity = $injector->getInstance('IMP_Identity'); + $identity->setDefault($identityId); + $from = $identity->getFromAddress(); + + $vCal = new Horde_Icalendar(); + $vCal->setAttribute('PRODID', '-//The Horde Project//' . strval(Horde_Mime_Headers_UserAgent::create()) . '//EN'); + $vCal->setAttribute('METHOD', 'DECLINECOUNTER'); + $counterEvent = clone $vevent; + $counterEvent->setAttribute('DTSTAMP', new Horde_Date('now', 'UTC')); + $vCal->addComponent($counterEvent); + + $body = new Horde_Mime_Part(); + $body->setType('text/plain'); + $body->setCharset('UTF-8'); + $body->setContents(HordeString::wrap(_('Your proposed new time was declined by the organizer.'), 76)); + + $ics = new Horde_Mime_Part(); + $ics->setType('text/calendar'); + $ics->setCharset('UTF-8'); + $ics->setContents($vCal->exportvCalendar()); + $ics->setName('icalendar.ics'); + $ics->setContentTypeParameter('METHOD', 'DECLINECOUNTER'); + + $mime = new Horde_Mime_Part(); + $mime[] = $body; + $mime[] = $ics; + + $headers = new Horde_Mime_Headers(); + $headers->addHeaderOb(Horde_Core_Mime_Headers_Received::createHordeHop()); + $headers->addHeaderOb(Horde_Mime_Headers_MessageId::create()); + $headers->addHeaderOb(Horde_Mime_Headers_Date::create()); + $headers->addHeader('From', $from); + $headers->addHeader('To', $toAddress); + + $replyto = $identity->getValue('replyto_addr'); + if (!empty($replyto) && !$from->match($replyto)) { + $headers->addHeader('Reply-To', $replyto); + } + $headers->addHeader('Subject', _('Decline Counter Proposal')); + + $mime->send($toAddress, $headers, $injector->getInstance('IMP_Mail')); + } + protected function _handlevEvent($key, array $components, $mime_part) { global $notification, $registry; diff --git a/lib/Mime/Viewer/Itip.php b/lib/Mime/Viewer/Itip.php index 87449a9de..9e2599a8f 100644 --- a/lib/Mime/Viewer/Itip.php +++ b/lib/Mime/Viewer/Itip.php @@ -378,6 +378,32 @@ protected function _vEvent($vevent, $id, $method = 'PUBLISH', $components = []) } break; + case 'COUNTER': + $desc = _('%s has proposed a new time for "%s".'); + $sender = $this->_senderFromHeader(); + if ($registry->hasMethod('calendar/updateAttendee') + && $this->_autoUpdateReply(self::AUTO_UPDATE_EVENT_REPLY, $sender)) { + try { + $registry->call('calendar/updateAttendee', [ + $vevent, + $sender, + true, + ]); + $notification->push(_('Counter proposal recorded.'), 'horde.success'); + } catch (Horde_Exception $e) { + $notification->push(sprintf(_('There was an error updating the event: %s'), $e->getMessage()), 'horde.error'); + } + } elseif ($registry->hasMethod('calendar/updateAttendee')) { + $options['update'] = _('Record proposed new time'); + } + if ($registry->hasMethod('calendar/replace')) { + $options['counter-accept'] = _('Accept proposed time'); + } + if ($registry->hasMethod('calendar/updateAttendee')) { + $options['counter-decline'] = _('Decline proposed time'); + } + break; + case 'CANCEL': try { $vevent->getAttributeSingle('RECURRENCE-ID'); diff --git a/test/Imp/Stub/ItipRequest.php b/test/Imp/Stub/ItipRequest.php index 57ff445b9..23fc1ccb1 100644 --- a/test/Imp/Stub/ItipRequest.php +++ b/test/Imp/Stub/ItipRequest.php @@ -13,6 +13,8 @@ * @subpackage UnitTests */ +use Horde\Util\Variables; + /** * Stub for testing the IMP Itip Imple handler. * @@ -26,7 +28,7 @@ */ class Imp_Stub_Ajax_Imple_ItipRequest extends IMP_Ajax_Imple_ItipRequest { - public function handle(Horde_Variables $vars) + public function handle(Horde_Variables|Variables $vars) { $this->_handle($vars); } diff --git a/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php b/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php new file mode 100644 index 000000000..83368e837 --- /dev/null +++ b/test/Imp/Unit/Ajax/Imple/ItipRequestCounterTest.php @@ -0,0 +1,376 @@ +_oldtz = date_default_timezone_get(); + date_default_timezone_set('UTC'); + + $injector = $this->getMockBuilder('Horde_Injector') + ->disableOriginalConstructor() + ->getMock(); + $injector->method('getInstance') + ->willReturnCallback([$this, '_injectorGetInstance']); + $GLOBALS['injector'] = $injector; + + $calendarApi = new class () { + public function listCalendars($all = false) + { + return []; + } + }; + + $registry = $this->getMockBuilder('Horde_Registry') + ->disableOriginalConstructor() + ->onlyMethods(['remoteHost', 'hasMethod', 'call', 'link', '__get']) + ->getMock(); + $registry->method('remoteHost') + ->willReturnCallback([$this, '_registryRemoteHost']); + $registry->method('hasMethod') + ->willReturnCallback([$this, '_registryHasMethod']); + $registry->method('call') + ->willReturnCallback([$this, '_registryCall']); + $registry->method('link') + ->willReturn('calendar/show'); + $registry->method('__get') + ->willReturnCallback(function ($api) use ($calendarApi) { + if ($api === 'calendar') { + return $calendarApi; + } + }); + $GLOBALS['registry'] = $registry; + + $notification = $this->getMockBuilder('Horde_Notification_Handler') + ->disableOriginalConstructor() + ->getMock(); + $notification->method('push') + ->willReturnCallback([$this, '_notificationHandler']); + $GLOBALS['notification'] = $notification; + + $GLOBALS['conf']['server']['name'] = 'localhost'; + $_SERVER['REMOTE_ADDR'] = 'localhost'; + + $browser = $this->getMockBuilder('Horde_Browser') + ->disableOriginalConstructor() + ->getMock(); + $browser->method('hasFeature') + ->willReturn(true); + $browser->method('usingSSLConnection') + ->willReturn(false); + $GLOBALS['browser'] = $browser; + } + + protected function tearDown(): void + { + date_default_timezone_set($this->_oldtz); + $this->_contents = null; + $this->_contentsFactory = null; + $this->_identity = null; + $this->_mail = null; + $this->_mailbox = null; + $this->_imapFactory = null; + $this->_notifyStack = []; + $this->_registryCalls = []; + } + + public function _injectorGetInstance($interface) + { + switch ($interface) { + case 'Horde_Core_Hooks': + return new Horde_Core_Hooks(); + + case 'IMP_Contents': + if (!isset($this->_contents)) { + $headers = new Horde_Mime_Headers(); + $headers->addHeader( + 'From', + '"Counter Attendee" ' + ); + + $contents = $this->getMockBuilder('IMP_Contents') + ->disableOriginalConstructor() + ->getMock(); + $contents->method('getMimePart') + ->willReturnCallback([$this, '_getMimePart']); + $contents->method('getHeader') + ->willReturn($headers); + $this->_contents = $contents; + } + return $this->_contents; + + case 'IMP_Factory_Contents': + if (!isset($this->_contentsFactory)) { + $cf = $this->getMockBuilder('IMP_Factory_Contents') + ->disableOriginalConstructor() + ->getMock(); + $cf->method('create') + ->willReturn($this->_injectorGetInstance('IMP_Contents')); + $this->_contentsFactory = $cf; + } + return $this->_contentsFactory; + + case 'IMP_Factory_Imap': + if (!isset($this->_imapFactory)) { + $imap = $this->getMockBuilder('IMP_Factory_Imap') + ->disableOriginalConstructor() + ->getMock(); + $imap->method('create') + ->willReturn(new IMP_Stub_Imap()); + $this->_imapFactory = $imap; + } + return $this->_imapFactory; + + case 'IMP_Factory_Mailbox': + if (!isset($this->_mailbox)) { + $mbox = $this->getMockBuilder('IMP_Factory_Mailbox') + ->disableOriginalConstructor() + ->getMock(); + $mbox->method('create') + ->willReturn(new IMP_Mailbox('foo')); + $this->_mailbox = $mbox; + } + return $this->_mailbox; + + case 'IMP_Identity': + if (!isset($this->_identity)) { + $identity = $this->getMockBuilder('Horde_Core_Prefs_Identity') + ->disableOriginalConstructor() + ->getMock(); + $identity->method('setDefault') + ->willReturnCallback([$this, '_identitySetDefault']); + $identity->method('getDefault') + ->willReturnCallback([$this, '_identityGetDefault']); + $identity->method('getFromAddress') + ->willReturnCallback([$this, '_identityGetFromAddress']); + $identity->method('getDefaultFromAddress') + ->willReturn(new Horde_Mail_Rfc822_Address('"Organizer" ')); + $identity->method('getValue') + ->willReturnCallback([$this, '_identityGetValue']); + $this->_identity = $identity; + } + return $this->_identity; + + case 'IMP_Mail': + if (!isset($this->_mail)) { + $this->_mail = new Horde_Mail_Transport_Mock(); + } + return $this->_mail; + + case 'Horde_Browser': + return $GLOBALS['browser']; + } + } + + public function _registryRemoteHost() + { + $remote = new stdClass(); + $remote->addr = '127.0.0.1'; + $remote->host = 'localhost'; + + return $remote; + } + + public function _registryHasMethod($method) + { + return in_array($method, [ + 'calendar/updateAttendee', + 'calendar/export', + 'calendar/replace', + 'calendar/import', + ], true); + } + + public function _registryCall($method, array $args = []) + { + $this->_registryCalls[] = [$method, $args]; + + switch ($method) { + case 'calendar/export': + throw new Horde_Exception('Event not found'); + + case 'calendar/import': + return 'counter-event-uid'; + } + } + + public function _getMimePart($id) + { + $part = new Horde_Mime_Part(); + $part->setContents($this->_contentsData); + $part->setType('text/calendar'); + return $part; + } + + public function _identitySetDefault($id) + { + $this->_identityId = $id; + } + + public function _identityGetDefault() + { + return $this->_identityId; + } + + public function _identityGetFromAddress($value = null) + { + return new Horde_Mail_Rfc822_Address('"Organizer" '); + } + + public function _identityGetValue($value, $identity = null) + { + switch ($value) { + case 'fullname': + return 'Organizer'; + + case 'replyto_addr': + return 'organizer@example.com'; + } + } + + public function _notificationHandler($msg, $code) + { + $this->_notifyStack[] = [$msg, $code]; + } + + public function testCounterDeclineSendsDeclineCounterMessage() + { + $this->_doRequest('counter-decline', $this->_getCounterCalendar()); + + $this->assertNotEmpty($this->_notifyStack); + $this->assertStringContainsString( + 'Decline counter sent.', + (string) $this->_notifyStack[0][0] + ); + $this->assertEquals('horde.success', $this->_notifyStack[0][1]); + + $mail = $GLOBALS['injector']->getInstance('IMP_Mail'); + $this->assertNotEmpty($mail->sentMessages); + $this->assertEquals( + 'Decline Counter Proposal', + $mail->sentMessages[0]['headers']['Subject'] + ); + $this->assertEquals( + 'counter.attendee@example.com', + $this->_getMailHeaders()->getValue('To') + ); + } + + public function testCounterAcceptStoresProposalAfterAcceptingEvent() + { + $this->_doRequest('counter-accept', $this->_getCounterCalendar(), 'default', true); + + $updateCalls = array_filter( + $this->_registryCalls, + function ($call) { + return $call[0] === 'calendar/updateAttendee'; + } + ); + $this->assertCount(1, $updateCalls); + $updateCall = reset($updateCalls); + $this->assertTrue($updateCall[1][2]); + $this->assertSame('counter.attendee@example.com', $updateCall[1][1]); + } + + public function testCounterUpdateStoresProposalForCounterMethod() + { + $this->_doRequest('update', $this->_getCounterCalendar()); + + $updateCalls = array_filter( + $this->_registryCalls, + function ($call) { + return $call[0] === 'calendar/updateAttendee'; + } + ); + $this->assertNotEmpty($updateCalls); + $this->assertTrue($updateCalls[array_key_first($updateCalls)][1][2]); + $this->assertStringContainsString( + 'Counter proposal recorded.', + (string) $this->_notifyStack[0][0] + ); + $this->assertEquals('horde.success', $this->_notifyStack[0][1]); + } + + private function _getCounterCalendar() + { + $originalStart = new Horde_Date('20080926T110000'); + $originalEnd = new Horde_Date('20080926T120000'); + $proposedStart = new Horde_Date('20080927T140000'); + $proposedEnd = new Horde_Date('20080927T150000'); + + $vCal = new Horde_Icalendar(); + $vCal->setAttribute('METHOD', 'COUNTER'); + $event = Horde_Icalendar::newComponent('VEVENT', $vCal); + $event->setAttribute('UID', 'counter-uid-1001'); + $event->setAttribute('SUMMARY', 'Counter Proposal'); + $event->setAttribute('ORGANIZER', 'mailto:organizer@example.com', ['CN' => 'Organizer']); + $event->setAttribute('DTSTART', $proposedStart->timestamp()); + $event->setAttribute('DTEND', $proposedEnd->timestamp()); + $event->setAttribute('ATTENDEE', 'mailto:counter.attendee@example.com', [ + 'CN' => 'Counter Attendee', + 'PARTSTAT' => 'NEEDS-ACTION', + ]); + $vCal->addComponent($event); + + return $vCal->exportvCalendar(); + } + + private function _doRequest($action, $data, $identity = 'default', $stubHandlevEvent = false) + { + $vars = new Horde_Variables([ + 'imple_submit' => ['imple_submit[0]' => $action], + 'identity' => $identity, + 'mailbox' => 'foo', + 'mime_id' => 1, + 'uid' => 1, + ]); + $this->_contentsData = $data; + + $imple = $stubHandlevEvent + ? new Imp_Stub_Ajax_Imple_ItipRequestCounterAccept([]) + : new Imp_Stub_Ajax_Imple_ItipRequest([]); + $imple->handle($vars); + } + + private function _getMailHeaders() + { + $mail = $GLOBALS['injector']->getInstance('IMP_Mail'); + $this->assertNotEmpty($mail->sentMessages); + $headers = Horde_Mime_Headers::parseHeaders($mail->sentMessages[0]['header_text']); + $this->assertInstanceOf('Horde_Mime_Headers', $headers); + return $headers; + } +}