76e97835df11c1915005951d3b6329e8c483ed18
[tine20] / tine20 / Calendar / Controller / EventNotifications.php
1 <?php
2 /**
3  * Calendar Event Notifications
4  * 
5  * @package     Calendar
6  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
7  * @author      Cornelius Weiss <c.weiss@metaways.de>
8  * @copyright   Copyright (c) 2009-2013 Metaways Infosystems GmbH (http://www.metaways.de)
9  */
10
11 /**
12  * Calendar Event Notifications
13  *
14  * @package     Calendar
15  */
16  class Calendar_Controller_EventNotifications
17  {
18      const NOTIFICATION_LEVEL_NONE                      =  0;
19      const NOTIFICATION_LEVEL_INVITE_CANCEL             = 10;
20      const NOTIFICATION_LEVEL_EVENT_RESCHEDULE          = 20;
21      const NOTIFICATION_LEVEL_EVENT_UPDATE              = 30;
22      const NOTIFICATION_LEVEL_ATTENDEE_STATUS_UPDATE    = 40;
23      
24      const INVITATION_ATTACHMENT_MAX_FILESIZE           = 2097152; // 2 MB
25      
26     /**
27      * @var Calendar_Controller_EventNotifications
28      */
29     private static $_instance = NULL;
30     
31     /**
32      * don't clone. Use the singleton.
33      *
34      */
35     private function __clone() 
36     {
37     }
38     
39     /**
40      * the singleton pattern
41      *
42      * @return Calendar_Controller_EventNotifications
43      */
44     public static function getInstance() 
45     {
46         if (self::$_instance === NULL) {
47             self::$_instance = new Calendar_Controller_EventNotifications();
48         }
49         
50         return self::$_instance;
51     }
52     
53     /**
54      * constructor
55      * 
56      */
57     private function __construct()
58     {
59         
60     }
61     
62     /**
63      * get updates of human interest
64      * 
65      * @param  Calendar_Model_Event $_event
66      * @param  Calendar_Model_Event $_oldEvent
67      * @return array
68      */
69     protected function _getUpdates($_event, $_oldEvent)
70     {
71         // check event details
72         $diff = $_event->diff($_oldEvent)->diff;
73         
74         $orderedUpdateFieldOfInterest = array(
75             'dtstart', 'dtend', 'rrule', 'summary', 'location', 'description',
76             'transp', 'priority', 'status', 'class',
77             'url', 'is_all_day_event', 'originator_tz', /*'tags', 'notes',*/
78         );
79         
80         $updates = array();
81         foreach ($orderedUpdateFieldOfInterest as $field) {
82             if ((isset($diff[$field]) || array_key_exists($field, $diff))) {
83                 $updates[$field] = $diff[$field];
84             }
85         }
86         
87         // rrule legacy
88         if ((isset($updates['rrule']) || array_key_exists('rrule', $updates))) {
89             $updates['rrule'] = $_oldEvent->rrule;
90         }
91         
92         // check for organizer update
93         if (Tinebase_Record_Abstract::convertId($_event['organizer'], 'Addressbook_Model_Contact') != 
94             Tinebase_Record_Abstract::convertId($_oldEvent['organizer'], 'Addressbook_Model_Contact')) {
95             
96             $updates['organizer'] = $_event->resolveOrganizer();
97         }
98         
99         // check attendee updates
100         $attendeeMigration = Calendar_Model_Attender::getMigration($_oldEvent->attendee, $_event->attendee);
101         foreach ($attendeeMigration['toUpdate'] as $attendee) {
102             $oldAttendee = Calendar_Model_Attender::getAttendee($_oldEvent->attendee, $attendee);
103             if ($attendee->status == $oldAttendee->status) {
104                 $attendeeMigration['toUpdate']->removeRecord($attendee);
105             }
106         }
107         
108         foreach($attendeeMigration as $action => $migration) {
109             Calendar_Model_Attender::resolveAttendee($migration, FALSE);
110             if (! count($migration)) {
111                 unset($attendeeMigration[$action]);
112             }
113         }
114         
115         if (! empty($attendeeMigration)) {
116             $updates['attendee'] = $attendeeMigration;
117         }
118         
119         return $updates;
120     }
121     
122     /**
123      * send notifications 
124      * 
125      * @param Calendar_Model_Event       $_event
126      * @param Tinebase_Model_FullAccount $_updater
127      * @param Sting                      $_action
128      * @param Calendar_Model_Event       $_oldEvent
129      * @param Tinebase_Model_Alarm       $_alarm
130      * @return void
131      */
132     public function doSendNotifications($_event, $_updater, $_action, $_oldEvent=NULL, $_alarm=NULL)
133     {
134         // we only send notifications to attendee
135         if (! $_event->attendee instanceof Tinebase_Record_RecordSet) {
136             return;
137         }
138
139         if ($_event->dtend === NULL) {
140             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " " . print_r($_event->toArray(), TRUE));
141             throw new Tinebase_Exception_UnexpectedValue('no dtend set in event');
142         }
143         
144         // skip notifications to past events
145         if (Tinebase_DateTime::now()->subHour(1)->isLater($_event->dtend)) {
146             if ($_action == 'alarm' || ! ($_event->isRecurException() || $_event->rrule)) {
147                 return;
148             }
149         }
150         
151         // lets resolve attendee once as batch to fill cache
152         $attendee = clone $_event->attendee;
153         Calendar_Model_Attender::resolveAttendee($attendee);
154         
155         switch ($_action) {
156             case 'alarm':
157                 foreach($_event->attendee as $attender) {
158                     if (Calendar_Model_Attender::isAlarmForAttendee($attender, $_alarm)) {
159                         $this->sendNotificationToAttender($attender, $_event, $_updater, $_action, self::NOTIFICATION_LEVEL_NONE);
160                     }
161                 }
162                 break;
163             case 'created':
164             case 'deleted':
165                 foreach($_event->attendee as $attender) {
166                     $this->sendNotificationToAttender($attender, $_event, $_updater, $_action, self::NOTIFICATION_LEVEL_INVITE_CANCEL);
167                 }
168                 break;
169             case 'changed':
170                 $attendeeMigration = Calendar_Model_Attender::getMigration($_oldEvent->attendee, $_event->attendee);
171                 
172                 foreach ($attendeeMigration['toCreate'] as $attender) {
173                     $this->sendNotificationToAttender($attender, $_event, $_updater, 'created', self::NOTIFICATION_LEVEL_INVITE_CANCEL);
174                 }
175                 
176                 foreach ($attendeeMigration['toDelete'] as $attender) {
177                     $this->sendNotificationToAttender($attender, $_oldEvent, $_updater, 'deleted', self::NOTIFICATION_LEVEL_INVITE_CANCEL);
178                 }
179                 
180                 // NOTE: toUpdate are all attendee to be notified
181                 if (count($attendeeMigration['toUpdate']) > 0) {
182                     $updates = $this->_getUpdates($_event, $_oldEvent);
183                     
184                     if (empty($updates)) {
185                         Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . " empty update, nothing to notify about");
186                         return;
187                     }
188                     
189                     // compute change type
190                     if (count(array_intersect(array('dtstart', 'dtend'), array_keys($updates))) > 0) {
191                         $notificationLevel = self::NOTIFICATION_LEVEL_EVENT_RESCHEDULE;
192                     } else if (count(array_diff(array_keys($updates), array('attendee'))) > 0) {
193                         $notificationLevel = self::NOTIFICATION_LEVEL_EVENT_UPDATE;
194                     } else {
195                         $notificationLevel = self::NOTIFICATION_LEVEL_ATTENDEE_STATUS_UPDATE;
196                     }
197                     
198                     // send notifications
199                     foreach ($attendeeMigration['toUpdate'] as $attender) {
200                         $this->sendNotificationToAttender($attender, $_event, $_updater, 'changed', $notificationLevel, $updates);
201                     }
202                 }
203                 
204                 break;
205                 
206             default:
207                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " unknown action '$_action'");
208                 break;
209                 
210         }
211         
212         // SEND REPLY/COUNTER to external organizer
213         if ($_event->organizer && ! $_event->resolveOrganizer()->account_id && count($_event->attendee) == 1) {
214             $updates = array('attendee' => array('toUpdate' => $_event->attendee));
215             $organizer = new Calendar_Model_Attender(array(
216                 'user_type'  => Calendar_Model_Attender::USERTYPE_USER,
217                 'user_id'    => $_event->resolveOrganizer()
218             ));
219             $this->sendNotificationToAttender($organizer, $_event, $_updater, 'changed', self::NOTIFICATION_LEVEL_ATTENDEE_STATUS_UPDATE, $updates);
220         }
221     }
222     
223     /**
224      * send notification to a single attender
225      * 
226      * @param Calendar_Model_Attender    $_attender
227      * @param Calendar_Model_Event       $_event
228      * @param Tinebase_Model_FullAccount $_updater
229      * @param string                     $_action
230      * @param string                     $_notificationLevel
231      * @param array                      $_updates
232      * @return void
233      */
234     public function sendNotificationToAttender($_attender, $_event, $_updater, $_action, $_notificationLevel, $_updates = NULL)
235     {
236         try {
237             $organizer = $_event->resolveOrganizer();
238             $organizerAccountId = $organizer->account_id;
239             $attendee = $_attender->getResolvedUser();
240             $attendeeAccountId = $_attender->getUserAccountId();
241             
242             $prefUserId = $attendeeAccountId ? $attendeeAccountId :
243                           ($organizerAccountId ? $organizerAccountId : 
244                           ($_event->created_by));
245             
246             try {
247                 $prefUser = Tinebase_User::getInstance()->getFullUserById($prefUserId);
248             } catch (Exception $e) {
249                 $prefUser = Tinebase_Core::getUser();
250                 $prefUserId = $prefUser->getId();
251             }
252             
253             // get prefered language, timezone and notification level
254             $locale = Tinebase_Translation::getLocale(Tinebase_Core::getPreference()->getValueForUser(Tinebase_Preference::LOCALE, $prefUserId));
255             $timezone = Tinebase_Core::getPreference()->getValueForUser(Tinebase_Preference::TIMEZONE, $prefUserId);
256             $translate = Tinebase_Translation::getTranslation('Calendar', $locale);
257             $sendLevel        = Tinebase_Core::getPreference('Calendar')->getValueForUser(Calendar_Preference::NOTIFICATION_LEVEL, $prefUserId);
258             $sendOnOwnActions = Tinebase_Core::getPreference('Calendar')->getValueForUser(Calendar_Preference::SEND_NOTIFICATION_OF_OWN_ACTIONS, $prefUserId);
259             
260             // external (non account) notification
261             if (!$attendeeAccountId) {
262                 // external organizer needs status updates
263                 $sendLevel = $organizer && $_attender->getEmail() == $organizer->getPreferedEmailAddress() ? 40 : 30;
264                 $sendOnOwnActions = false;
265             }
266             
267             // check if user wants this notification NOTE: organizer gets mails unless she set notificationlevel to NONE
268             // NOTE prefUser is organzier for external notifications
269             if (($attendeeAccountId == $_updater->getId() && ! $sendOnOwnActions) || ($sendLevel < $_notificationLevel && ($attendeeAccountId != $organizerAccountId || $sendLevel == self::NOTIFICATION_LEVEL_NONE))) {
270                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " Preferred notification level not reached -> skipping notification for {$_attender->getEmail()}");
271                 return;
272             }
273             
274             $method = NULL;
275             $messageSubject = $this->_getSubject($_event, $_notificationLevel, $_action, $_updates, $timezone, $locale, $translate, $method);
276             
277             $view = new Zend_View();
278             $view->setScriptPath(dirname(__FILE__) . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'views');
279             
280             $view->translate    = $translate;
281             $view->timezone     = $timezone;
282             
283             $view->event        = $_event;
284             $view->updater      = $_updater;
285             $view->updates      = $_updates;
286             
287             $messageBody = $view->render('eventNotification.php');
288             
289             $calendarPart = null;
290             $attachments = $this->_getAttachments($method, $_event, $_action, $_updater, $calendarPart);
291             
292             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " receiver: '{$_attender->getEmail()}'");
293             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " subject: '$messageSubject'");
294             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " body: $messageBody");
295             
296             $sender = $_action == 'alarm' ? $prefUser : $_updater;
297         
298             Tinebase_Notification::getInstance()->send($sender, array($attendee), $messageSubject, $messageBody, $calendarPart, $attachments);
299         } catch (Exception $e) {
300             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " exception: " . $e);
301             return;
302         }
303     }
304     
305     /**
306      * get notification subject and method
307      * 
308      * @param Calendar_Model_Event $_event
309      * @param string $_notificationLevel
310      * @param string $_action
311      * @param array $_updates
312      * @param string $timezone
313      * @param Zend_Locale $locale
314      * @param Zend_Translate $translate
315      * @param atring $method
316      * @return string
317      */
318     protected function _getSubject($_event, $_notificationLevel, $_action, $_updates, $timezone, $locale, $translate, &$method)
319     {
320         $startDateString = Tinebase_Translation::dateToStringInTzAndLocaleFormat($_event->dtstart, $timezone, $locale);
321         $endDateString = Tinebase_Translation::dateToStringInTzAndLocaleFormat($_event->dtend, $timezone, $locale);
322         
323         switch ($_action) {
324             case 'alarm':
325                 $messageSubject = sprintf($translate->_('Alarm for event "%1$s" at %2$s'), $_event->summary, $startDateString);
326                 break;
327             case 'created':
328                 $messageSubject = sprintf($translate->_('Event invitation "%1$s" at %2$s'), $_event->summary, $startDateString);
329                 $method = Calendar_Model_iMIP::METHOD_REQUEST;
330                 break;
331             case 'deleted':
332                 $messageSubject = sprintf($translate->_('Event "%1$s" at %2$s has been canceled' ), $_event->summary, $startDateString);
333                 $method = Calendar_Model_iMIP::METHOD_CANCEL;
334                 break;
335             case 'changed':
336                 switch ($_notificationLevel) {
337                     case self::NOTIFICATION_LEVEL_EVENT_RESCHEDULE:
338                         if ((isset($_updates['dtstart']) || array_key_exists('dtstart', $_updates))) {
339                             $oldStartDateString = Tinebase_Translation::dateToStringInTzAndLocaleFormat($_updates['dtstart'], $timezone, $locale);
340                             $messageSubject = sprintf($translate->_('Event "%1$s" has been rescheduled from %2$s to %3$s' ), $_event->summary, $oldStartDateString, $startDateString);
341                             $method = Calendar_Model_iMIP::METHOD_REQUEST;
342                             break;
343                         }
344                         // fallthrough if dtstart didn't change
345                         
346                     case self::NOTIFICATION_LEVEL_EVENT_UPDATE:
347                         $messageSubject = sprintf($translate->_('Event "%1$s" at %2$s has been updated' ), $_event->summary, $startDateString);
348                         $method = Calendar_Model_iMIP::METHOD_REQUEST;
349                         break;
350                         
351                     case self::NOTIFICATION_LEVEL_ATTENDEE_STATUS_UPDATE:
352                         if(! empty($_updates['attendee']) && ! empty($_updates['attendee']['toUpdate']) && count($_updates['attendee']['toUpdate']) == 1) {
353                             // single attendee status update
354                             $attender = $_updates['attendee']['toUpdate']->getFirstRecord();
355                             
356                             switch ($attender->status) {
357                                 case Calendar_Model_Attender::STATUS_ACCEPTED:
358                                     $messageSubject = sprintf($translate->_('%1$s accepted event "%2$s" at %3$s' ), $attender->getName(), $_event->summary, $startDateString);
359                                     break;
360                                     
361                                 case Calendar_Model_Attender::STATUS_DECLINED:
362                                     $messageSubject = sprintf($translate->_('%1$s declined event "%2$s" at %3$s' ), $attender->getName(), $_event->summary, $startDateString);
363                                     break;
364                                     
365                                 case Calendar_Model_Attender::STATUS_TENTATIVE:
366                                     $messageSubject = sprintf($translate->_('Tentative response from %1$s for event "%2$s" at %3$s' ), $attender->getName(), $_event->summary, $startDateString);
367                                     break;
368                                     
369                                 case Calendar_Model_Attender::STATUS_NEEDSACTION:
370                                     $messageSubject = sprintf($translate->_('No response from %1$s for event "%2$s" at %3$s' ), $attender->getName(), $_event->summary, $startDateString);
371                                     break;
372                             }
373                         } else {
374                             $messageSubject = sprintf($translate->_('Attendee changes for event "%1$s" at %2$s' ), $_event->summary, $startDateString);
375                         }
376                         
377                         // we don't send iMIP parts to organizers with an account cause event is already up to date
378                         if ($_event->organizer && !$_event->resolveOrganizer()->account_id) {
379                             $method = Calendar_Model_iMIP::METHOD_REPLY;
380                         }
381                         break;
382                 }
383                 break;
384             default:
385                 $messageSubject = 'unknown action';
386                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " unknown action '$_action'");
387                 break;
388         }
389         
390         return $messageSubject;
391     }
392     
393     /**
394      * get notification attachments
395      * 
396      * @param string $method
397      * @param Calendar_Model_Event $_event
398      * @param string $_action
399      * @param Tinebase_Model_FullAccount $_updater
400      * @param Zend_Mime_Part $calendarPart
401      * @return array
402      */
403     protected function _getAttachments($method, $_event, $_action, $_updater, &$calendarPart)
404     {
405         if ($method === NULL) {
406             return array();
407         }
408         
409         $converter = Calendar_Convert_Event_VCalendar_Factory::factory(Calendar_Convert_Event_VCalendar_Factory::CLIENT_GENERIC);
410         $converter->setMethod($method);
411         $vcalendar = $converter->fromTine20Model($_event);
412
413         // in Tine 2.0 non organizers might be given the grant to update events
414         // @see rfc6047 section 2.2.1 & rfc5545 section 3.2.18
415         if ($method != Calendar_Model_iMIP::METHOD_REPLY && $_event->organizer !== $_updater->contact_id) {
416             foreach ($vcalendar->children() as $component) {
417                 if ($component->name == 'VEVENT') {
418                     if (isset($component->{'ORGANIZER'})) {
419                         $component->{'ORGANIZER'}->add('SENT-BY', 'mailto:' . $_updater->accountEmailAddress);
420                     }
421                 }
422             }
423         }
424         
425         // @TODO in Tine 2.0 status updater might not be updater
426         if ($method == Calendar_Model_iMIP::METHOD_REPLY) {
427             foreach ($vcalendar->children() as $component) {
428                 if ($component->name == 'VEVENT') {
429                     $component->{'REQUEST-STATUS'} = '2.0;Success';
430                 }
431             }
432         }
433         
434         $calendarPart           = new Zend_Mime_Part($vcalendar->serialize());
435         $calendarPart->charset  = 'UTF-8';
436         $calendarPart->type     = 'text/calendar; method=' . $method;
437         $calendarPart->encoding = Zend_Mime::ENCODING_QUOTEDPRINTABLE;
438         
439         $attachment = new Zend_Mime_Part($vcalendar->serialize());
440         $attachment->type     = 'application/ics';
441         $attachment->encoding = Zend_Mime::ENCODING_QUOTEDPRINTABLE;
442         $attachment->disposition = Zend_Mime::DISPOSITION_ATTACHMENT;
443         $attachment->filename = 'event.ics';
444         
445         $attachments = array($attachment);
446         
447         // add other attachments (only on invitation)
448         if ($_action == 'created') {
449             $eventAttachments = $this->_getEventAttachments($_event);
450             $attachments = array_merge($attachments, $eventAttachments);
451         }
452         
453         return $attachments;
454     }
455     
456     /**
457      * get event attachments
458      * 
459      * @param Calendar_Model_Event $_event
460      * @return array of Zend_Mime_Part
461      */
462     protected function _getEventAttachments($_event)
463     {
464         $attachments = array();
465         foreach ($_event->attachments as $attachment) {
466             if ($attachment->size < self::INVITATION_ATTACHMENT_MAX_FILESIZE) {
467                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
468                     . " Adding attachment " . $attachment->name . ' to invitation mail');
469                 
470                 $path = Tinebase_Model_Tree_Node_Path::STREAMWRAPPERPREFIX
471                     . Tinebase_FileSystem_RecordAttachments::getInstance()->getRecordAttachmentPath($_event)
472                     . '/' . $attachment->name;
473                 
474                 $handle = fopen($path, 'r');
475                 $stream = fopen("php://temp", 'r+');
476                 stream_copy_to_stream($handle, $stream);
477                 rewind($stream);
478
479                 $part              = new Zend_Mime_Part($stream);
480                 $part->encoding    = Zend_Mime::ENCODING_BASE64; // ?
481                 $part->filename    = $attachment->name;
482                 $part->setTypeAndDispositionForAttachment($attachment->contenttype, $attachment->name);
483                 
484                 fclose($handle);
485                 
486                 $attachments[] = $part;
487                 
488             } else {
489                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
490                     . " Not adding attachment " . $attachment->name . ' to invitation mail (size: ' . convertToMegabytes($attachment-size) . ')');
491             }
492         }
493         
494         return $attachments;
495     }
496  }