diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index d29cdcef..ec3ba00c 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -1,950 +1,957 @@ * * Copyright (C) 2011-2014, Kolab Systems AG * * 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 . */ class libcalendaring_itip { protected $rc; protected $lib; protected $plugin; protected $sender; protected $domain; protected $itip_send = false; protected $rsvp_actions = array('accepted','tentative','declined','delegated'); protected $rsvp_status = array('accepted','tentative','declined','delegated'); function __construct($plugin, $domain = 'libcalendaring') { $this->plugin = $plugin; $this->rc = rcube::get_instance(); $this->lib = libcalendaring::get_instance(); $this->domain = $domain; $hook = $this->rc->plugins->exec_hook('calendar_load_itip', array('identity' => $this->rc->user->list_emails(true))); $this->sender = $hook['identity']; $this->plugin->add_hook('smtp_connect', array($this, 'smtp_connect_hook')); } public function set_sender_email($email) { if (!empty($email)) $this->sender['email'] = $email; } public function set_rsvp_actions($actions) { $this->rsvp_actions = (array)$actions; $this->rsvp_status = array_merge($this->rsvp_actions, array('delegated')); } public function set_rsvp_status($status) { $this->rsvp_status = $status; } /** * Wrapper for rcube_plugin::gettext() * Checking for a label in different domains * * @see rcube::gettext() */ public function gettext($p) { $label = is_array($p) ? $p['name'] : $p; $domain = $this->domain; if (!$this->rc->text_exists($label, $domain)) { $domain = 'libcalendaring'; } return $this->rc->gettext($p, $domain); } /** * Send an iTip mail message * * @param array Event object to send * @param string iTip method (REQUEST|REPLY|CANCEL) * @param array Hash array with recipient data (name, email) * @param string Mail subject * @param string Mail body text label * @param object Mail_mime object with message data * @param boolean Request RSVP * @return boolean True on success, false on failure */ public function send_itip_message($event, $method, $recipient, $subject, $bodytext, $message = null, $rsvp = true) { if (!$this->sender['name']) { $this->sender['name'] = $this->sender['email']; } if (!$message) { libcalendaring::identify_recurrence_instance($event); $message = $this->compose_itip_message($event, $method, $rsvp); } $mailto = rcube_utils::idn_to_ascii($recipient['email']); $headers = $message->headers(); $headers['To'] = format_email_recipient($mailto, $recipient['name']); $headers['Subject'] = $this->gettext(array( 'name' => $subject, 'vars' => array( 'title' => $event['title'], 'name' => $this->sender['name'], ) )); // compose a list of all event attendees $attendees_list = array(); foreach ((array)$event['attendees'] as $attendee) { $attendees_list[] = ($attendee['name'] && $attendee['email']) ? $attendee['name'] . ' <' . $attendee['email'] . '>' : ($attendee['name'] ? $attendee['name'] : $attendee['email']); } $recurrence_info = ''; if (!empty($event['recurrence_id'])) { $recurrence_info = "\n\n** " . $this->gettext($event['thisandfuture'] ? 'itipmessagefutureoccurrence' : 'itipmessagesingleoccurrence') . ' **'; } else if (!empty($event['recurrence'])) { $recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence'])); } $mailbody = $this->gettext(array( 'name' => $bodytext, 'vars' => array( 'title' => $event['title'], 'date' => $this->lib->event_date_text($event, true) . $recurrence_info, 'attendees' => join(",\n ", $attendees_list), 'sender' => $this->sender['name'], 'organizer' => $this->sender['name'], 'description' => $event['description'], ) )); // remove redundant empty lines (e.g. when an event description is empty) $mailbody = preg_replace('/\n{3,}/', "\n\n", $mailbody); // if (!empty($event['comment'])) { // $mailbody .= "\n\n" . $this->gettext('itipsendercomment') . $event['comment']; // } // append links for direct invitation replies if ($method == 'REQUEST' && $rsvp && $this->rc->config->get('calendar_itip_smtp_server') && ($token = $this->store_invitation($event, $recipient['email'])) ) { $mailbody .= "\n\n" . $this->gettext(array( 'name' => 'invitationattendlinks', 'vars' => array('url' => $this->plugin->get_url(array('action' => 'attend', 't' => $token))), )); } else if ($method == 'CANCEL' && $event['cancelled']) { $this->cancel_itip_invitation($event); } $message->headers($headers, true); $message->setTXTBody(rcube_mime::format_flowed($mailbody, 79)); if ($this->rc->config->get('libcalendaring_itip_debug', false)) { rcube::console('iTip ' . $method, $message->txtHeaders() . "\r\n" . $message->get()); } // finally send the message $this->itip_send = true; $sent = $this->rc->deliver_message($message, $headers['X-Sender'], $mailto, $smtp_error); $this->itip_send = false; return $sent; } /** * Plugin hook to alter SMTP authentication. * This is used if iTip messages are to be sent from an unauthenticated session */ public function smtp_connect_hook($p) { // replace smtp auth settings if we're not in an authenticated session if ($this->itip_send && !$this->rc->user->ID) { foreach (array('smtp_server', 'smtp_user', 'smtp_pass') as $prop) { $p[$prop] = $this->rc->config->get("calendar_itip_$prop", $p[$prop]); } } - return $p; + return $p; } /** * Helper function to build a Mail_mime object to send an iTip message * * @param array Event object to send * @param string iTip method (REQUEST|REPLY|CANCEL) * @param boolean Request RSVP * @return object Mail_mime object with message data */ public function compose_itip_message($event, $method, $rsvp = true) { $from = rcube_utils::idn_to_ascii($this->sender['email']); $from_utf = rcube_utils::idn_to_utf8($from); $sender = format_email_recipient($from, $this->sender['name']); // truncate list attendees down to the recipient of the iTip Reply. // constraints for a METHOD:REPLY according to RFC 5546 if ($method == 'REPLY') { $replying_attendee = null; $reply_attendees = array(); foreach ($event['attendees'] as $attendee) { if ($attendee['role'] == 'ORGANIZER') { $reply_attendees[] = $attendee; } else if (strcasecmp($attendee['email'], $from) == 0 || strcasecmp($attendee['email'], $from_utf) == 0) { $replying_attendee = $attendee; if ($attendee['status'] != 'DELEGATED') { unset($replying_attendee['rsvp']); // unset the RSVP attribute } } // include attendees relevant for delegation (RFC 5546, Section 4.2.5) else if ((!empty($attendee['delegated-to']) && (strcasecmp($attendee['delegated-to'], $from) == 0 || strcasecmp($attendee['delegated-to'], $from_utf) == 0)) || (!empty($attendee['delegated-from']) && (strcasecmp($attendee['delegated-from'], $from) == 0 || strcasecmp($attendee['delegated-from'], $from_utf) == 0))) { $reply_attendees[] = $attendee; } } if ($replying_attendee) { array_unshift($reply_attendees, $replying_attendee); $event['attendees'] = $reply_attendees; } if ($event['recurrence']) { unset($event['recurrence']['EXCEPTIONS']); } } // set RSVP for every attendee else if ($method == 'REQUEST') { foreach ($event['attendees'] as $i => $attendee) { if (($rsvp || !isset($attendee['rsvp'])) && ($attendee['status'] != 'DELEGATED' && $attendee['role'] != 'NON-PARTICIPANT')) { $event['attendees'][$i]['rsvp']= (bool)$rsvp; } } } else if ($method == 'CANCEL') { if ($event['recurrence']) { unset($event['recurrence']['EXCEPTIONS']); } } // Set SENT-BY property if the sender is not the organizer if ($method == 'CANCEL' || $method == 'REQUEST') { foreach ((array)$event['attendees'] as $idx => $attendee) { if ($attendee['role'] == 'ORGANIZER' && $attendee['email'] && strcasecmp($attendee['email'], $from) != 0 && strcasecmp($attendee['email'], $from_utf) != 0 ) { $attendee['sent-by'] = 'mailto:' . $from_utf; $event['organizer'] = $event['attendees'][$idx] = $attendee; break; } } } // compose multipart message using PEAR:Mail_Mime $message = new Mail_mime("\r\n"); $message->setParam('text_encoding', 'quoted-printable'); $message->setParam('head_encoding', 'quoted-printable'); $message->setParam('head_charset', RCUBE_CHARSET); $message->setParam('text_charset', RCUBE_CHARSET . ";\r\n format=flowed"); $message->setContentType('multipart/alternative'); // compose common headers array $headers = array( 'From' => $sender, 'Date' => $this->rc->user_date(), 'Message-ID' => $this->rc->gen_message_id(), 'X-Sender' => $from, ); if ($agent = $this->rc->config->get('useragent')) { $headers['User-Agent'] = $agent; } $message->headers($headers); // attach ics file for this event $ical = libcalendaring::get_ical(); $ics = $ical->export(array($event), $method, false, $method == 'REQUEST' && $this->plugin->driver ? array($this->plugin->driver, 'get_attachment_body') : false); $filename = $event['_type'] == 'task' ? 'todo.ics' : 'event.ics'; $message->addAttachment($ics, 'text/calendar', $filename, false, '8bit', '', RCUBE_CHARSET . "; method=" . $method); return $message; } /** * Forward the given iTip event as delegation to another person * * @param array Event object to delegate * @param mixed Delegatee as string or hash array with keys 'name' and 'mailto' * @param boolean The delegator's RSVP flag * @param array List with indexes of new/updated attendees * @return boolean True on success, False on failure */ public function delegate_to(&$event, $delegate, $rsvp = false, &$attendees = array()) { if (is_string($delegate)) { $delegates = rcube_mime::decode_address_list($delegate, 1, false); if (count($delegates) > 0) { $delegate = reset($delegates); } } $emails = $this->lib->get_user_emails(); $me = $this->rc->user->list_emails(true); // find/create the delegate attendee $delegate_attendee = array( 'email' => $delegate['mailto'], 'name' => $delegate['name'], 'role' => 'REQ-PARTICIPANT', ); $delegate_index = count($event['attendees']); foreach ($event['attendees'] as $i => $attendee) { // set myself the DELEGATED-TO parameter if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['delegated-to'] = $delegate['mailto']; $event['attendees'][$i]['status'] = 'DELEGATED'; $event['attendees'][$i]['role'] = 'NON-PARTICIPANT'; $event['attendees'][$i]['rsvp'] = $rsvp; $me['email'] = $attendee['email']; $delegate_attendee['role'] = $attendee['role']; } // the disired delegatee is already listed as an attendee else if (stripos($delegate['mailto'], $attendee['email']) !== false && $attendee['role'] != 'ORGANIZER') { $delegate_attendee = $attendee; $delegate_index = $i; break; } // TODO: remove previous delegatee (i.e. attendee that has DELEGATED-FROM == $me) } // set/add delegate attendee with RSVP=TRUE and DELEGATED-FROM parameter $delegate_attendee['rsvp'] = true; $delegate_attendee['status'] = 'NEEDS-ACTION'; $delegate_attendee['delegated-from'] = $me['email']; $event['attendees'][$delegate_index] = $delegate_attendee; $attendees[] = $delegate_index; $this->set_sender_email($me['email']); return $this->send_itip_message($event, 'REQUEST', $delegate_attendee, 'itipsubjectdelegatedto', 'itipmailbodydelegatedto'); } /** * Handler for calendar/itip-status requests */ public function get_itip_status($event, $existing = null) { - $action = $event['rsvp'] ? 'rsvp' : ''; - $status = $event['fallback']; - $latest = $rescheduled = false; - $html = ''; + $action = $event['rsvp'] ? 'rsvp' : ''; + $status = $event['fallback']; + $latest = $rescheduled = false; + $html = ''; - if (is_numeric($event['changed'])) - $event['changed'] = new DateTime('@'.$event['changed']); + if (is_numeric($event['changed'])) { + $event['changed'] = new DateTime('@'.$event['changed']); + } - // check if the given itip object matches the last state - if ($existing) { - $latest = (isset($event['sequence']) && intval($existing['sequence']) == intval($event['sequence'])) || + // check if the given itip object matches the last state + if ($existing) { + $latest = (isset($event['sequence']) && intval($existing['sequence']) == intval($event['sequence'])) || (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] >= $event['changed']); - } + } - // determine action for REQUEST - if ($event['method'] == 'REQUEST') { - $html = html::div('rsvp-status', $this->gettext('acceptinvitation')); + // determine action for REQUEST + if ($event['method'] == 'REQUEST') { + $html = html::div('rsvp-status', $this->gettext('acceptinvitation')); - if ($existing) { - $rsvp = $event['rsvp']; - $emails = $this->lib->get_user_emails(); - foreach ($existing['attendees'] as $attendee) { - if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { - $status = strtoupper($attendee['status']); - break; - } - } + if ($existing) { + $rsvp = $event['rsvp']; + $emails = $this->lib->get_user_emails(); - // Detect re-sheduling - if (!$latest) { - // FIXME: This is probably to simplistic, or maybe we should just check - // attendee's RSVP flag in the new event? - $rescheduled = $existing['start'] != $event['start'] || $existing['end'] > $event['end']; - } - } - else { - $rsvp = $event['rsvp'] && $this->rc->config->get('calendar_allow_itip_uninvited', true); - } + foreach ($existing['attendees'] as $attendee) { + if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { + $status = strtoupper($attendee['status']); + break; + } + } - $status_lc = strtolower($status); + // Detect re-sheduling + if (!$latest) { + // FIXME: This is probably to simplistic, or maybe we should just check + // attendee's RSVP flag in the new event? + $rescheduled = $existing['start'] != $event['start'] || $existing['end'] > $event['end']; + } + } + else { + $rsvp = $event['rsvp'] && $this->rc->config->get('calendar_allow_itip_uninvited', true); + } - if ($status_lc == 'unknown' && !$this->rc->config->get('calendar_allow_itip_uninvited', true)) { - $html = html::div('rsvp-status', $this->gettext('notanattendee')); - $action = 'import'; - } - else if (in_array($status_lc, $this->rsvp_status)) { - $status_text = $this->gettext(($latest ? 'youhave' : 'youhavepreviously') . $status_lc); + $status_lc = strtolower($status); - if ($existing && ($existing['sequence'] > $event['sequence'] || (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] > $event['changed']))) { - $action = ''; // nothing to do here, outdated invitation - if ($status_lc == 'needs-action') - $status_text = $this->gettext('outdatedinvitation'); - } - else if (!$existing && !$rsvp) { - $action = 'import'; - } - else if ($rescheduled) { - $action = 'rsvp'; - } - else if ($status_lc != 'needs-action') { - // check if there are any changes - if ($latest) { - $diff = $this->get_itip_diff($event, $existing); - $latest = empty($diff); + if ($status_lc == 'unknown' && !$this->rc->config->get('calendar_allow_itip_uninvited', true)) { + $html = html::div('rsvp-status', $this->gettext('notanattendee')); + $action = 'import'; } + else if (in_array($status_lc, $this->rsvp_status)) { + $status_text = $this->gettext(($latest ? 'youhave' : 'youhavepreviously') . $status_lc); - $action = !$latest ? 'update' : ''; - } + if ($existing && ($existing['sequence'] > $event['sequence'] + || (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] > $event['changed'])) + ) { + $action = ''; // nothing to do here, outdated invitation + if ($status_lc == 'needs-action') { + $status_text = $this->gettext('outdatedinvitation'); + } + } + else if (!$existing && !$rsvp) { + $action = 'import'; + } + else if ($rescheduled) { + $action = 'rsvp'; + } + else if ($status_lc != 'needs-action') { + // check if there are any changes + if ($latest) { + $diff = $this->get_itip_diff($event, $existing); + $latest = empty($diff); + } - $html = html::div('rsvp-status ' . $status_lc, $status_text); - } - } - // determine action for REPLY - else if ($event['method'] == 'REPLY') { - // check whether the sender already is an attendee - if ($existing) { - // Relax checking if that is a reply to the latest version of the event - // We accept versions with older SEQUENCE but no significant changes (Bifrost#T78144) - if (!$latest) { - $num = $got = 0; - foreach (array('start', 'end', 'due', 'allday', 'recurrence', 'location') as $key) { - if (isset($existing[$key])) { - if ($key == 'allday') { - $event[$key] = $event[$key] == 'true'; + $action = !$latest ? 'update' : ''; } - $value = $existing[$key] instanceof DateTime ? $existing[$key]->format('c') : $existing[$key]; - $num++; - $got += intval($value == $event[$key]); - } + + $html = html::div('rsvp-status ' . $status_lc, $status_text); } + } + // determine action for REPLY + else if ($event['method'] == 'REPLY') { + // check whether the sender already is an attendee + if ($existing) { + // Relax checking if that is a reply to the latest version of the event + // We accept versions with older SEQUENCE but no significant changes (Bifrost#T78144) + if (!$latest) { + $num = $got = 0; + foreach (array('start', 'end', 'due', 'allday', 'recurrence', 'location') as $key) { + if (isset($existing[$key])) { + if ($key == 'allday') { + $event[$key] = $event[$key] == 'true'; + } + $value = $existing[$key] instanceof DateTime ? $existing[$key]->format('c') : $existing[$key]; + $num++; + $got += intval($value == $event[$key]); + } + } - $latest = $num === $got; - } + $latest = $num === $got; + } - $action = $this->rc->config->get('calendar_allow_itip_uninvited', true) ? 'accept' : ''; - $listed = false; - foreach ($existing['attendees'] as $attendee) { - if ($attendee['role'] != 'ORGANIZER' && strcasecmp($attendee['email'], $event['attendee']) == 0) { - $status_lc = strtolower($status); - if (in_array($status_lc, $this->rsvp_status)) { - $html = html::div('rsvp-status ' . $status_lc, $this->gettext(array( - 'name' => 'attendee' . $status_lc, - 'vars' => array( - 'delegatedto' => rcube::Q($event['delegated-to'] ?: ($attendee['delegated-to'] ?: '?')), - ) - ))); - } - $action = $attendee['status'] == $status || !$latest ? '' : 'update'; - $listed = true; - break; - } - } + $action = $this->rc->config->get('calendar_allow_itip_uninvited', true) ? 'accept' : ''; + $listed = false; + + foreach ($existing['attendees'] as $attendee) { + if ($attendee['role'] != 'ORGANIZER' && strcasecmp($attendee['email'], $event['attendee']) == 0) { + $status_lc = strtolower($status); + if (in_array($status_lc, $this->rsvp_status)) { + $html = html::div('rsvp-status ' . $status_lc, $this->gettext(array( + 'name' => 'attendee' . $status_lc, + 'vars' => array( + 'delegatedto' => rcube::Q($event['delegated-to'] ?: ($attendee['delegated-to'] ?: '?')), + ) + ))); + } - if (!$listed) { - $html = html::div('rsvp-status', $this->gettext('itipnewattendee')); - } - } - else { - $html = html::div('rsvp-status hint', $this->gettext('itipobjectnotfound')); - $action = ''; + $action = $attendee['status'] == $status || !$latest ? '' : 'update'; + $listed = true; + break; + } + } + + if (!$listed) { + $html = html::div('rsvp-status', $this->gettext('itipnewattendee')); + } + } + else { + $html = html::div('rsvp-status hint', $this->gettext('itipobjectnotfound')); + $action = ''; + } } - } - else if ($event['method'] == 'CANCEL') { - if (!$existing) { - $html = html::div('rsvp-status hint', $this->gettext('itipobjectnotfound')); - $action = ''; + else if ($event['method'] == 'CANCEL') { + if (!$existing) { + $html = html::div('rsvp-status hint', $this->gettext('itipobjectnotfound')); + $action = ''; + } } - } - return array( - 'uid' => $event['uid'], - 'id' => asciiwords($event['uid'], true), - 'existing' => $existing ? true : false, - 'saved' => $existing ? true : false, - 'latest' => $latest, - 'status' => $status, - 'action' => $action, - 'rescheduled' => $rescheduled, - 'html' => $html, - ); + return array( + 'uid' => $event['uid'], + 'id' => asciiwords($event['uid'], true), + 'existing' => $existing ? true : false, + 'saved' => $existing ? true : false, + 'latest' => $latest, + 'status' => $status, + 'action' => $action, + 'rescheduled' => $rescheduled, + 'html' => $html, + ); } protected function get_itip_diff($event, $existing) { if (empty($event) || empty($existing) || empty($event['message_uid'])) { return; } $itip = $this->lib->mail_get_itip_object($event['mbox'], $event['message_uid'], $event['mime_id'], $event['task'] == 'calendar' ? 'event' : 'task'); if ($itip) { // List of properties that could change without SEQUENCE bump $attrs = array('description', 'title', 'location', 'url'); $diff = array(); foreach ($attrs as $attr) { if (isset($itip[$attr]) && $itip[$attr] != $existing[$attr]) { $diff[$attr] = array( 'new' => $itip[$attr], 'old' => $existing[$attr] ); } } $status = array(); $itip_attendees = array(); $existing_attendees = array(); $emails = $this->lib->get_user_emails(); // Compare list of attendees (ignoring current user status) foreach ((array) $existing['attendees'] as $idx => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $status[strtolower($attendee['email'])] = $attendee['status']; } if ($attendee['role'] == 'ORGANIZER') { $attendee['status'] = 'ACCEPTED'; // sometimes is not set for exceptions $existing['attendees'][$idx] = $attendee; } $existing_attendees[] = $attendee['email'].$attendee['name']; } foreach ((array) $itip['attendees'] as $idx => $attendee) { if ($attendee['email'] && ($_status = $status[strtolower($attendee['email'])])) { $attendee['status'] = $_status; $itip['attendees'][$idx] = $attendee; } $itip_attendees[] = $attendee['email'].$attendee['name']; } if ($itip_attendees != $existing_attendees) { $diff['attendees'] = array( 'new' => $itip['attendees'], 'old' => $existing['attendees'] ); } return $diff; } } /** * Build inline UI elements for iTip messages */ public function mail_itip_inline_ui($event, $method, $mime_id, $task, $message_date = null, $preview_url = null) { $buttons = array(); $dom_id = asciiwords($event['uid'], true); $rsvp_status = 'unknown'; // pass some metadata about the event and trigger the asynchronous status check $changed = is_object($event['changed']) ? $event['changed'] : $message_date; $metadata = array( 'uid' => $event['uid'], '_instance' => $event['_instance'], 'changed' => $changed ? $changed->format('U') : 0, 'sequence' => intval($event['sequence']), 'method' => $method, 'task' => $task, 'mime_id' => $mime_id, ); // create buttons to be activated from async request checking existence of this event in local calendars $buttons[] = html::div(array('id' => 'loading-'.$dom_id, 'class' => 'rsvp-status loading'), $this->gettext('loading')); // on iTip REPLY we have two options: if ($method == 'REPLY') { $title = $this->gettext('itipreply'); foreach ($event['attendees'] as $attendee) { if (!empty($attendee['email']) && $attendee['role'] != 'ORGANIZER') { if (empty($event['_sender']) || self::compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { $metadata['attendee'] = $attendee['email']; $rsvp_status = strtoupper($attendee['status']); if ($attendee['delegated-to']) { $metadata['delegated-to'] = $attendee['delegated-to']; } break; } } } // 1. update the attendee status on our copy $update_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updateattendeestatus'), )); // 2. accept or decline a new or delegate attendee $accept_buttons = html::tag('input', array( 'type' => 'button', 'class' => "button accept", 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('acceptattendee'), )); $accept_buttons .= html::tag('input', array( 'type' => 'button', 'class' => "button decline", 'onclick' => "rcube_libcalendaring.decline_attendee_reply('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('declineattendee'), )); $buttons[] = html::div(array('id' => 'update-'.$dom_id, 'style' => 'display:none'), $update_button); $buttons[] = html::div(array('id' => 'accept-'.$dom_id, 'style' => 'display:none'), $accept_buttons); // For replies we need more metadata foreach (array('start', 'end', 'due', 'allday', 'recurrence', 'location') as $key) { if (isset($event[$key])) { $metadata[$key] = $event[$key] instanceof DateTime ? $event[$key]->format('c') : $event[$key]; } } } // when receiving iTip REQUEST messages: else if ($method == 'REQUEST') { $emails = $this->lib->get_user_emails(); $title = $event['sequence'] > 0 ? $this->gettext('itipupdate') : $this->gettext('itipinvitation'); $metadata['rsvp'] = true; $metadata['sensitivity'] = $event['sensitivity']; if (is_object($event['start'])) { $metadata['date'] = $event['start']->format('U'); } // check for X-KOLAB-INVITATIONTYPE property and only show accept/decline buttons if (self::get_custom_property($event, 'X-KOLAB-INVITATIONTYPE') == 'CONFIRMATION') { $this->rsvp_actions = array('accepted','declined'); $metadata['nosave'] = true; } // 1. display RSVP buttons (if the user was invited) foreach ($this->rsvp_actions as $method) { $rsvp_buttons .= html::tag('input', array( 'type' => 'button', 'class' => "button $method", 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task', '$method', '$dom_id')", 'value' => $this->gettext('itip' . $method), )); } // add button to open calendar/preview if (!empty($preview_url)) { $msgref = $this->lib->ical_message->folder . '/' . $this->lib->ical_message->uid . '#' . $mime_id; $rsvp_buttons .= html::tag('input', array( 'type' => 'button', 'class' => "button preview", 'onclick' => "rcube_libcalendaring.open_itip_preview('" . rcube::JQ($preview_url) . "', '" . rcube::JQ($msgref) . "')", 'value' => $this->gettext('openpreview'), )); } // 2. update the local copy with minor changes $update_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updatemycopy'), )); // 3. Simply import the event without replying $import_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('importtocalendar'), )); // check my status foreach ($event['attendees'] as $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; $rsvp_status = !empty($attendee['status']) ? strtoupper($attendee['status']) : 'NEEDS-ACTION'; break; } } // add itip reply message controls $rsvp_buttons .= html::div('itip-reply-controls', $this->itip_rsvp_options_ui($dom_id, $metadata['nosave'])); $buttons[] = html::div(array('id' => 'rsvp-'.$dom_id, 'class' => 'rsvp-buttons', 'style' => 'display:none'), $rsvp_buttons); $buttons[] = html::div(array('id' => 'update-'.$dom_id, 'style' => 'display:none'), $update_button); // prepare autocompletion for delegation dialog if (in_array('delegated', $this->rsvp_actions)) { $this->rc->autocomplete_init(); } } // for CANCEL messages, we can: else if ($method == 'CANCEL') { $title = $this->gettext('itipcancellation'); $event_prop = array_filter(array( 'uid' => $event['uid'], '_instance' => $event['_instance'], '_savemode' => $event['_savemode'], )); // 1. remove the event from our calendar $button_remove = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.remove_from_itip(" . rcube_output::json_serialize($event_prop) . ", '$task', '" . rcube::JQ($event['title']) . "')", 'value' => $this->gettext('removefromcalendar'), )); // 2. update our copy with status=cancelled $button_update = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updatemycopy'), )); $buttons[] = html::div(array('id' => 'rsvp-'.$dom_id, 'style' => 'display:none'), $button_remove . $button_update); $rsvp_status = 'CANCELLED'; $metadata['rsvp'] = true; } // append generic import button if ($import_button) { $buttons[] = html::div(array('id' => 'import-'.$dom_id, 'style' => 'display:none'), $import_button); } // pass some metadata about the event and trigger the asynchronous status check $metadata['fallback'] = $rsvp_status; $metadata['rsvp'] = intval($metadata['rsvp']); $this->rc->output->add_script("rcube_libcalendaring.fetch_itip_object_status(" . rcube_output::json_serialize($metadata) . ")", 'docready'); // get localized texts from the right domain foreach (array('savingdata','deleteobjectconfirm','declinedeleteconfirm','declineattendee', 'cancel','itipdelegated','declineattendeeconfirm','itipcomment','delegateinvitation', 'delegateto','delegatersvpme','delegateinvalidaddress') as $label) { $this->rc->output->command('add_label', "itip.$label", $this->gettext($label)); } // show event details with buttons return $this->itip_object_details_table($event, $title) . html::div(array('class' => 'itip-buttons', 'id' => 'itip-buttons-' . asciiwords($metadata['uid'], true)), join('', $buttons)); } /** * Render an RSVP UI widget with buttons to respond on iTip invitations */ function itip_rsvp_buttons($attrib = array(), $actions = null) { $attrib += array('type' => 'button'); if (!$actions) $actions = $this->rsvp_actions; foreach ($actions as $method) { $buttons .= html::tag('input', array( 'type' => $attrib['type'], 'name' => $attrib['iname'], 'class' => 'button', 'rel' => $method, 'value' => $this->gettext('itip' . $method), )); } // add localized texts for the delegation dialog if (in_array('delegated', $actions)) { foreach (array('itipdelegated','itipcomment','delegateinvitation', 'delegateto','delegatersvpme','delegateinvalidaddress','cancel') as $label) { $this->rc->output->command('add_label', "itip.$label", $this->gettext($label)); } } foreach (array('all','current','future') as $mode) { $this->rc->output->command('add_label', "rsvpmode$mode", $this->gettext("rsvpmode$mode")); } $savemode_radio = new html_radiobutton(array('name' => '_rsvpmode', 'class' => 'rsvp-replymode')); return html::div($attrib, html::div('label', $this->gettext('acceptinvitation')) . html::div('rsvp-buttons', $buttons . html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id'])) ) ); } /** * Render UI elements to control iTip reply message sending */ public function itip_rsvp_options_ui($dom_id, $disable = false) { $itip_sending = $this->rc->config->get('calendar_itip_send_option', 3); // itip sending is entirely disabled if ($itip_sending === 0) { return ''; } // add checkbox to suppress itip reply message else if ($itip_sending >= 2) { $rsvp_additions = html::label(array('class' => 'noreply-toggle'), html::tag('input', array('type' => 'checkbox', 'id' => 'noreply-'.$dom_id, 'value' => 1, 'disabled' => $disable, 'checked' => ($itip_sending & 1) == 0)) . ' ' . $this->gettext('itipsuppressreply') ); } // add input field for reply comment $toggle_attrib = array( 'href' => '#toggle', 'class' => 'reply-comment-toggle', 'onclick' => '$(this).hide().parent().find(\'textarea\').show().focus()' ); $textarea_attrib = array( 'id' => 'reply-comment-' . $dom_id, 'name' => '_comment', 'cols' => 40, 'rows' => 6, 'style' => 'display:none', 'placeholder' => $this->gettext('itipcomment') ); $rsvp_additions .= html::a($toggle_attrib, $this->gettext('itipeditresponse')) . html::div('itip-reply-comment', html::tag('textarea', $textarea_attrib, '')); return $rsvp_additions; } /** * Render event/task details in a table */ function itip_object_details_table($event, $title) { $table = new html_table(array('cols' => 2, 'border' => 0, 'class' => 'calendar-eventdetails')); $table->add('ititle', $title); $table->add('title', rcube::Q($event['title'])); if ($event['start'] && $event['end']) { $table->add('label', $this->gettext('date')); $table->add('date', rcube::Q($this->lib->event_date_text($event))); } else if ($event['due'] && $event['_type'] == 'task') { $table->add('label', $this->gettext('date')); $table->add('date', rcube::Q($this->lib->event_date_text($event))); } if (!empty($event['recurrence_date'])) { $table->add('label', ''); $table->add('recurrence-id', $this->gettext($event['thisandfuture'] ? 'itipfutureoccurrence' : 'itipsingleoccurrence')); } else if (!empty($event['recurrence'])) { $table->add('label', $this->gettext('recurring')); $table->add('recurrence', $this->lib->recurrence_text($event['recurrence'])); } if ($event['location'] && trim($event['location'])) { $table->add('label', $this->gettext('location')); $table->add('location', rcube::Q($event['location'])); } if ($event['sensitivity'] && !preg_match('/^(x-|public$)/i', $event['sensitivity'])) { $table->add('label', $this->gettext('sensitivity')); $table->add('sensitivity', ucfirst($this->gettext($event['sensitivity'])) . '!'); } if ($event['status'] == 'COMPLETED' || $event['status'] == 'CANCELLED') { $table->add('label', $this->gettext('status')); $table->add('status', $this->gettext('status-' . strtolower($event['status']))); } if ($event['comment'] && trim($event['comment'])) { $table->add('label', $this->gettext('comment')); $table->add('location', rcube::Q($event['comment'])); } return $table->show(); } /** * Create iTIP invitation token for later replies via URL * * @param array Hash array with event properties * @param string Attendee email address * @return string Invitation token */ public function store_invitation($event, $attendee) { // empty stub return false; } /** * Mark invitations for the given event as cancelled * * @param array Hash array with event properties */ public function cancel_itip_invitation($event) { // empty stub return false; } /** * Utility function to get the value of a custom property */ public static function get_custom_property($event, $name) { $ret = false; if (is_array($event['x-custom'])) { array_walk($event['x-custom'], function($prop, $i) use ($name, &$ret) { if (strcasecmp($prop[0], $name) === 0) { $ret = $prop[1]; } }); } return $ret; } /** * Compare email address */ public static function compare_email($value, $email, $email_utf = null) { $v1 = !empty($email) && strcasecmp($value, $email) === 0; $v2 = !empty($email_utf) && strcasecmp($value, $email_utf) === 0; return $v1 || $v2; } }