Copyright (c) 2006-2013 osTicket http://www.osticket.com Released under the GNU General Public License WITHOUT ANY WARRANTY. See LICENSE.TXT for details. vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ include_once(INCLUDE_DIR.'class.ticket.php'); include_once(INCLUDE_DIR.'class.draft.php'); include_once(INCLUDE_DIR.'class.role.php'); //Ticket thread. class Thread extends VerySimpleModel implements Searchable { static $meta = array( 'table' => THREAD_TABLE, 'pk' => array('id'), 'joins' => array( 'ticket' => array( 'constraint' => array( 'object_type' => "'T'", 'object_id' => 'Ticket.ticket_id', ), ), 'task' => array( 'constraint' => array( 'object_type' => "'A'", 'object_id' => 'Task.id', ), ), 'collaborators' => array( 'reverse' => 'Collaborator.thread', ), 'referrals' => array( 'reverse' => 'ThreadReferral.thread', ), 'entries' => array( 'reverse' => 'ThreadEntry.thread', ), 'events' => array( 'reverse' => 'ThreadEvent.thread', 'broker' => 'ThreadEvents', ), ), ); const MODE_STAFF = 1; const MODE_CLIENT = 2; var $_object; var $_entries; var $_collaborators; // Cache for collabs var $_participants; function getId() { return $this->id; } function getObjectId() { return $this->object_id; } function getObjectType() { return $this->object_type; } function getObject() { if (!$this->_object) $this->_object = ObjectModel::lookup( $this->getObjectId(), $this->getObjectType()); return $this->_object; } function getNumAttachments() { return Attachment::objects()->filter(array( 'thread_entry__thread' => $this ))->count(); } function getNumEntries() { return $this->entries->count(); } function getEntries($criteria=false) { if (!isset($this->_entries)) { $this->_entries = $this->entries->annotate(array( 'has_attachments' => SqlAggregate::COUNT(SqlCase::N() ->when(array('attachments__inline'=>0), 1) ->otherwise(null) ), )); $this->_entries->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN)); if ($criteria) $this->_entries->filter($criteria); } return $this->_entries; } // Referrals function getNumReferrals() { return $this->referrals->count(); } function getReferrals() { return $this->referrals; } // Collaborators function getNumCollaborators() { return $this->getCollaborators()->count(); } function getNumActiveCollaborators() { if (!isset($this->ht['active_collaborators'])) $this->ht['active_collaborators'] = count($this->getActiveCollaborators()); return $this->ht['active_collaborators']; } function getActiveCollaborators() { $collaborators = $this->getCollaborators(); $active = array(); foreach ($collaborators as $c) { if ($c->isActive()) $active[] = $c; } return $active; } function getCollaborators($criteria=array()) { if ($this->_collaborators && !$criteria) return $this->_collaborators; $collaborators = $this->collaborators ->filter(array('thread_id' => $this->getId())); if (isset($criteria['isactive'])) $collaborators->filter(array('flags__hasbit'=>Collaborator::FLAG_ACTIVE)); // TODO: sort by name of the user $collaborators->order_by('user__name'); if (!$criteria) $this->_collaborators = $collaborators; return $collaborators; } function isCollaborator($user) { return $this->collaborators->findFirst(array( 'user_id' => $user->getId(), 'thread_id' => $this->getId())); } function addCollaborator($user, $vars, &$errors, $event=true) { global $cfg, $thisstaff; if (!$user) return null; if ($this->isCollaborator($user)) return false; $vars = array_merge(array( 'threadId' => $this->getId(), 'userId' => $user->getId()), $vars ?: array()); if (!($c=Collaborator::add($vars, $errors))) return null; $c->active = true; // Disable Agent Collabs (if configured) for User created tickets if (!$thisstaff && $this->object_type === 'T' && $cfg->disableAgentCollaborators() && Staff::lookup($user->getDefaultEmailAddress())) $c->active = false; $this->_collaborators = null; if ($event) { $vars['add'] = true; $this->logCollaboratorEvents($user, $vars); } return $c; } function updateCollaborators($vars, &$errors) { global $thisstaff; if (!$thisstaff) return; //Deletes if($vars['del'] && ($ids=array_filter($vars['del']))) { $collabs = array(); foreach ($ids as $k => $cid) { if (($c=Collaborator::lookup($cid)) && ($c->getThreadId() == $this->getId()) && $c->delete()) $collabs[] = $c; $this->logCollaboratorEvents($c, $vars); } } //statuses $cids = null; if($vars['cid'] && ($cids=array_filter($vars['cid']))) { $this->collaborators->filter(array( 'thread_id' => $this->getId(), 'id__in' => $cids ))->update(array( 'updated' => SqlFunction::NOW(), )); foreach ($vars['cid'] as $c) { $collab = Collaborator::lookup($c); if (($collab instanceof Collaborator)) { $collab->setFlag(Collaborator::FLAG_ACTIVE, true); $collab->save(); } } } $inactive = $this->collaborators->filter(array( 'thread_id' => $this->getId(), Q::not(array('id__in' => $cids ?: array(0))) )); if($inactive) { foreach ($inactive as $i) { $i->setFlag(Collaborator::FLAG_ACTIVE, false); $i->save(); } $inactive->update(array( 'updated' => SqlFunction::NOW(), )); } unset($this->ht['active_collaborators']); $this->_collaborators = null; return true; } function logCollaboratorEvents($collaborator, $vars) { $name = $collaborator->getName()->getOriginal(); $userId = (get_class($collaborator) == 'User') ? $collaborator->getId() : $collaborator->user_id; $action = $vars['del'] ? 'object.deleted' : 'object.created'; $addDel = $vars['del'] ? 'del' : 'add'; $this->getEvents()->log($this->getObject(), 'collab', array( $addDel => array($userId => array('name' => $name)) )); $type = array('type' => 'collab', $addDel => array($userId => array( 'name' => $name, 'src' => @$vars['source'], ))); Signal::send($action, $this->getObject(), $type); } //UserList of participants (collaborators) function getParticipants() { if (!isset($this->_participants)) { $list = new UserList(); if ($collabs = $this->getActiveCollaborators()) { foreach ($collabs as $c) $list->add($c); } $this->_participants = $list; } return $this->_participants; } // MailingList of recipients (collaborators) function getRecipients() { $list = new MailingList(); if ($collabs = $this->getActiveCollaborators()) { foreach ($collabs as $c) $list->addCc($c); } return $list; } function getReferral($id, $type) { return $this->referrals->findFirst(array( 'object_id' => $id, 'object_type' => $type)); } function isReferred($to=null, $strict=false) { if (is_null($to) || !$this->referrals) return ($this->referrals && $this->referrals->count()); switch (true) { case $to instanceof Staff: // Referred to the staff if ($this->getReferral($to->getId(), ObjectModel::OBJECT_TYPE_STAFF)) return true; // Strict check only checks the Agent if ($strict) return false; // Referred to staff's department if ($this->referrals->findFirst(array( 'object_id__in' => $to->getDepts(), 'object_type' => ObjectModel::OBJECT_TYPE_DEPT))) return true; // Referred to staff's teams if ($to->getTeams() && $this->referrals->findFirst(array( 'object_id__in' => $to->getTeams(), 'object_type' => ObjectModel::OBJECT_TYPE_TEAM ))) return true; return false; break; case $to instanceof Team: //Referred to a Team return ($this->getReferral($to->getId(), ObjectModel::OBJECT_TYPE_TEAM)); break; case $to instanceof Dept: // Refered to the dept return ($this->getReferral($to->getId(), ObjectModel::OBJECT_TYPE_DEPT)); break; } return false; } function refer($to) { if ($this->isReferred($to, true)) return false; $vars = array('thread_id' => $this->getId()); switch (true) { case $to instanceof Staff: $vars['object_id'] = $to->getId(); $vars['object_type'] = ObjectModel::OBJECT_TYPE_STAFF; break; case $to instanceof Team: $vars['object_id'] = $to->getId(); $vars['object_type'] = ObjectModel::OBJECT_TYPE_TEAM; break; case $to instanceof Dept: $vars['object_id'] = $to->getId(); $vars['object_type'] = ObjectModel::OBJECT_TYPE_DEPT; break; default: return false; } return ThreadReferral::create($vars); } // Render thread function render($type=false, $options=array()) { $mode = $options['mode'] ?: self::MODE_STAFF; // Register thread actions prior to rendering the thread. if (!class_exists('tea_showemailheaders')) include_once INCLUDE_DIR . 'class.thread_actions.php'; $entries = $this->getEntries(); if ($type && is_array($type)) { $visibility = Q::all(array('type__in' => $type)); if ($type['user_id']) { $visibility->add(array('user_id' => $type['user_id'])); $visibility->ored = true; } $entries->filter($visibility); } if ($options['sort'] && !strcasecmp($options['sort'], 'DESC')) $entries->order_by('-id'); // Precache all the attachments on this thread AttachmentFile::objects()->filter(array( 'attachments__thread_entry__thread__id' => $this->id ))->all(); $events = $this->getEvents(); $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR; include $inc . 'templates/thread-entries.tmpl.php'; } function getEntry($id) { return ThreadEntry::lookup($id, $this->getId()); } function getEvents() { return $this->events; } /** * postEmail * * After some security and sanity checks, attaches the body and subject * of the message in reply to this thread item * * Parameters: * mailinfo - (array) of information about the email, with at least the * following keys * - mid - (string) email message-id * - name - (string) personal name of email originator * - email - (string) originating email address * - subject - (string) email subject line (decoded) * - body - (string) email message body (decoded) */ function postEmail($mailinfo, $entry=null) { // +==================+===================+=============+ // | Orig Thread-Type | Reply Thread-Type | Requires | // +==================+===================+=============+ // | * | Message (M) | From: Owner | // | * | Note (N) | From: Staff | // | Response (R) | Message (M) | | // | Message (M) | Response (R) | From: Staff | // +------------------+-------------------+-------------+ if (!$object = $this->getObject()) { // How should someone find this thread? return false; } elseif ($object instanceof Ticket && ( !$mailinfo['staffId'] && $object->isClosed() && !$object->isReopenable() )) { // Ticket is closed, not reopenable, and email was not submitted // by an agent. Email cannot be submitted return false; } $vars = array( 'mid' => $mailinfo['mid'], 'header' => $mailinfo['header'], 'poster' => $mailinfo['name'], 'origin' => 'Email', 'source' => 'Email', 'ip' => '', 'reply_to' => $entry, 'recipients' => $mailinfo['recipients'], 'thread_entry_recipients' => $mailinfo['thread_entry_recipients'], 'to-email-id' => $mailinfo['to-email-id'], 'autorespond' => !isset($mailinfo['passive']), ); // XXX: Is this necessary? if ($object instanceof Ticket) $vars['ticketId'] = $object->getId(); if ($object instanceof Task) $vars['taskId'] = $object->getId(); $errors = array(); if (isset($mailinfo['attachments'])) $vars['attachments'] = $mailinfo['attachments']; $body = $mailinfo['message']; // extra handling for determining Cc collabs if ($mailinfo['email']) { $staffSenderId = Staff::getIdByEmail($mailinfo['email']); if (!$staffSenderId) { $senderId = UserEmailModel::getIdByEmail($mailinfo['email']); if ($senderId) { $mailinfo['userId'] = $senderId; if ($object instanceof Ticket && $senderId != $object->user_id && $senderId != $object->staff_id) { $mailinfo['userClass'] = 'C'; $collaboratorId = Collaborator::getIdByUserId($senderId, $this->getId()); $collaborator = Collaborator::lookup($collaboratorId); if ($collaborator && ($collaborator->isCc())) $vars['thread-type'] = 'M'; } } } } // Attempt to determine the user posting the entry and the // corresponding entry type by the information determined by the // mail parser (via the In-Reply-To header) switch ($mailinfo['userClass']) { case 'C': # Thread collaborator $vars['flags'] = ThreadEntry::FLAG_COLLABORATOR; case 'U': # Ticket owner $vars['thread-type'] = 'M'; $vars['userId'] = $mailinfo['userId']; break; case 'A': # System administrator case 'S': # Staff member (agent) $vars['thread-type'] = 'R'; $vars['staffId'] = $mailinfo['staffId']; if ($vars['staffId']) $vars['poster'] = Staff::lookup($mailinfo['staffId']); break; // The user type was not identified by the mail parsing system. It // is likely that the In-Reply-To and References headers were not // properly brokered by the user's mail client. Use the old logic to // determine the post type. default: // Disambiguate if the user happens also to be a staff member of // the system. The current ticket owner should _always_ post // messages instead of notes or responses if ($object instanceof Ticket && strcasecmp($mailinfo['email'], $object->getEmail()) == 0 ) { $vars['thread-type'] = 'M'; $vars['userId'] = $object->getUserId(); } elseif (Staff::getIdByEmail($mailinfo['email'])) { $vars['thread-type'] = 'R'; $vars['staffId'] = Staff::getIdByEmail($mailinfo['email']); $vars['poster'] = Staff::lookup($mailinfo['staffId']); } // Consider collaborator role (disambiguate staff members as // collaborators). Normally, the block above should match based // on the Referenced message-id header elseif ($C = $this->collaborators->filter(array( 'user__emails__address' => $mailinfo['email'] ))->first()) { $vars['thread-type'] = 'M'; // XXX: There's no way that mailinfo[userId] would be set $vars['userId'] = $mailinfo['userId'] ?: $C->getUserId(); $vars['flags'] = ThreadEntry::FLAG_COLLABORATOR; } // Don't process the email -- it came FROM this system elseif (Email::getIdByEmail($mailinfo['email'])) { return false; } } // Ensure we record the name of the person posting $vars['poster'] = $vars['poster'] ?: $mailinfo['name'] ?: $mailinfo['email']; // TODO: Consider security constraints if (!$vars['thread-type']) { //XXX: Are we potentially leaking the email address to // collaborators? // Try not to destroy the format of the body $header = sprintf( _S('Received From: %1$s <%2$s>') . "\n\n", $mailinfo['name'], $mailinfo['email']); if ($body instanceof HtmlThreadEntryBody) $header = nl2br(Format::htmlchars($header)); // Add the banner to the top of the message if ($body instanceof ThreadEntryBody) $body->prepend($header); $vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner? $vars['thread-type'] = 'M'; } if ($mailinfo['system_emails'] && ($t = $this->getObject()) && $t instanceof Ticket) $t->systemReferral($mailinfo['system_emails']); switch ($vars['thread-type']) { case 'M': $vars['message'] = $body; if ($object instanceof Threadable) { $entry = $object->postThreadEntry('M', $vars); if ($this->getObjectType() == 'C') { if ($object->isChild()) { $parent = Ticket::lookup($object->getPid()); ThreadEntry::setExtra(array($entry), array('thread' => $this->getId()), $parent->getThread()->getId()); } } return $entry; } elseif ($this instanceof ObjectThread) return $this->addMessage($vars, $errors); break; case 'N': $vars['note'] = $body; if ($object instanceof Threadable) return $object->postThreadEntry('N', $vars); elseif ($this instanceof ObjectThread) return $this->addNote($vars, $errors); break; case 'R': $vars['response'] = $body; $vars['reply-to'] = 'all'; $vars['emailcollab'] = $object->getActiveCollaborators(); return $object->postReplyCustom($vars, $errors, true, true); break; } throw new Exception('Unable to continue thread via email.'); // Currently impossible, but indicate that this thread object could // not append the incoming email. return false; } function deleteAttachments() { $deleted = Attachment::objects()->filter(array( 'thread_entry__thread' => $this, ))->delete(); if ($deleted) AttachmentFile::deleteOrphans(); return $deleted; } function removeCollaborators() { return Collaborator::objects() ->filter(array('thread_id'=>$this->getId())) ->delete(); } function setExtra($mergedThread, $info='') { if ($info && $info['extra']) { $extra = json_decode($info['extra'], true); $entries = ThreadEntry::objects()->filter(array('thread_id' => $info['threadId'])); foreach ($entries as $entry) $entry->saveExtra($entry, array('thread' => $info['threadId']), $mergedThread->getId()); } else ThreadEntry::setExtra($this->getEntries(), array('thread' => $this->getId()), $mergedThread->getId()); $this->object_type = 'C'; $number = Ticket::objects()->filter(array('ticket_id'=>$this->getObjectId()))->values_flat('number')->first(); $this->extra = json_encode(array('ticket_id' => $mergedThread->getObjectId(), 'number' => $extra['number'] ?: $number[0])); $this->save(); } /** * Function: lookupByEmailHeaders * * Attempt to locate a thread by the email headers. It should be * considered a secondary lookup to ThreadEntry::lookupByEmailHeaders(), * which should find an actual thread entry, which should be possible * for all email communcation which is associated with a thread entry. * The only time where this is useful is for threads which triggered * email communication without a thread entry, for instance, like * tickets created without an initial message. */ function lookupByEmailHeaders(&$mailinfo) { $possibles = array(); foreach (array('mid', 'in-reply-to', 'references') as $header) { $matches = array(); if (!isset($mailinfo[$header]) || !$mailinfo[$header]) continue; // Header may have multiple entries (usually separated by // spaces ( ) elseif (!preg_match_all('/<([^>@]+@[^>]+)>/', $mailinfo[$header], $matches)) continue; // The References header will have the most recent message-id // (parent) on the far right. // @see rfc 1036, section 2.2.5 // @see http://www.jwz.org/doc/threading.html $possibles = array_merge($possibles, array_reverse($matches[1])); } // Add the message id if it is embedded in the body $match = array(); if (preg_match('`(?:class="mid-|Ref-Mid: )([^"\s]*)(?:$|")`', $mailinfo['message'], $match) && !in_array($match[1], $possibles) ) { $possibles[] = $match[1]; } foreach ($possibles as $mid) { // Attempt to detect the ticket and user ids from the // message-id header. If the message originated from // osTicket, the Mailer class can break it apart. If it came // from this help desk, the 'loopback' property will be set // to true. $mid_info = Mailer::decodeMessageId($mid); if (!$mid_info || !$mid_info['loopback']) continue; if (isset($mid_info['uid']) && @$mid_info['threadId'] && ($t = Thread::lookup($mid_info['threadId'])) ) { if (@$mid_info['userId']) { $mailinfo['userId'] = $mid_info['userId']; } elseif (@$mid_info['staffId']) { $mailinfo['staffId'] = $mid_info['staffId']; } // ThreadEntry was positively identified return $t; } } return null; } static function getSearchableFields() { return array( 'lastmessage' => new DatetimeField(array( 'label' => __('Last Message'), )), 'lastresponse' => new DatetimeField(array( 'label' => __('Last Response'), )), ); } static function supportsCustomData() { false; } function delete() { //Self delete if (!parent::delete()) return false; // Clear email meta data (header..etc) ThreadEntryEmailInfo::objects() ->filter(array('thread_entry__thread' => $this)) ->update(array('headers' => null)); // Mass delete entries $this->deleteAttachments(); $this->removeCollaborators(); $this->entries->delete(); // Null out the events $this->events->update(array('thread_id' => 0)); return true; } static function create($vars=false) { $inst = new static($vars); $inst->created = SqlFunction::NOW(); return $inst; } } class ThreadEntryEmailInfo extends VerySimpleModel { static $meta = array( 'table' => THREAD_ENTRY_EMAIL_TABLE, 'pk' => array('id'), 'joins' => array( 'thread_entry' => array( 'constraint' => array('thread_entry_id' => 'ThreadEntry.id'), ), ), ); } class ThreadEntryMergeInfo extends VerySimpleModel { static $meta = array( 'table' => THREAD_ENTRY_MERGE_TABLE, 'pk' => array('id'), 'joins' => array( 'thread_entry' => array( 'constraint' => array('thread_entry_id' => 'ThreadEntry.id'), ), ), ); } class ThreadEntry extends VerySimpleModel implements TemplateVariable { static $meta = array( 'table' => THREAD_ENTRY_TABLE, 'pk' => array('id'), 'select_related' => array('staff', 'user', 'email_info'), 'ordering' => array('created', 'id'), 'joins' => array( 'thread' => array( 'constraint' => array('thread_id' => 'Thread.id'), ), 'parent' => array( 'constraint' => array('pid' => 'ThreadEntry.id'), 'null' => true, ), 'children' => array( 'reverse' => 'ThreadEntry.parent', ), 'email_info' => array( 'reverse' => 'ThreadEntryEmailInfo.thread_entry', 'list' => false, ), 'merge_info' => array( 'reverse' => 'ThreadEntryMergeInfo.thread_entry', 'list' => false, ), 'attachments' => array( 'reverse' => 'Attachment.thread_entry', 'null' => true, ), 'staff' => array( 'constraint' => array('staff_id' => 'Staff.staff_id'), 'null' => true, ), 'user' => array( 'constraint' => array('user_id' => 'User.id'), 'null' => true, ), ), ); const FLAG_ORIGINAL_MESSAGE = 0x0001; const FLAG_EDITED = 0x0002; const FLAG_HIDDEN = 0x0004; const FLAG_GUARDED = 0x0008; // No replace on edit const FLAG_RESENT = 0x0010; const FLAG_COLLABORATOR = 0x0020; // Message from collaborator const FLAG_BALANCED = 0x0040; // HTML does not need to be balanced on ::display() const FLAG_SYSTEM = 0x0080; // Entry is a system note. const FLAG_REPLY_ALL = 0x00100; // Agent response, reply all const FLAG_REPLY_USER = 0x00200; // Agent response, reply to User const FLAG_CHILD = 0x00400; // Entry is from a child Ticket const PERM_EDIT = 'thread.edit'; var $_headers; var $_body; var $_thread; var $_actions; var $is_autoreply; var $is_bounce; static protected $perms = array( self::PERM_EDIT => array( 'title' => /* @trans */ 'Edit Thread', 'desc' => /* @trans */ 'Ability to edit thread items of other agents', ), ); // Thread entry types static protected $types = array( 'M' => 'message', 'R' => 'response', 'N' => 'note', ); function getTypeName() { return self::$types[$this->type]; } function postEmail($mailinfo) { global $ost; if (!($thread = $this->getThread())) // Kind of hard to continue a discussion without a thread ... return false; elseif ($this->getEmailMessageId() == $mailinfo['mid']) // Reporting success so the email can be moved or deleted. return true; // Mail sent by this system will have a predictable message-id // If this incoming mail matches the code, then it very likely // originated from this system and looped $info = Mailer::decodeMessageId($mailinfo['mid']); if ($info && $info['loopback']) { // This mail was sent by this system. It was received due to // some kind of mail delivery loop. It should not be considered // a response to an existing thread entry if ($ost) $ost->log(LOG_ERR, _S('Email loop detected'), sprintf( _S('It appears as though <%s> is being used as a forwarded or fetched email account and is also being used as a user / system account. Please correct the loop or seek technical assistance.'), $mailinfo['email']), // This is quite intentional -- don't continue the loop false, // Force the message, even if logging is disabled true); return $this; } return $thread->postEmail($mailinfo, $this); } function getId() { return $this->id; } function getPid() { return $this->get('pid', 0); } function getParent() { return $this->parent; } function getType() { return $this->type; } function getSource() { return $this->source; } function getPoster() { return $this->poster; } function getTitle() { return $this->title; } function getBody() { if (!isset($this->_body)) { $body = $this->body; if ($body == null && $this->getNumAttachments()) { $attachments = Attachment::objects() ->filter(array( 'inline' => 1, 'object_id' => $this->getId(), 'type' => ObjectModel::OBJECT_TYPE_THREAD, 'file__type__in' => array('text/html','text/plain')) ); foreach ($attachments as $a) if ($a->inline && ($f=$a->getFile())) $body .= $f->getData(); } $this->_body = ThreadEntryBody::fromFormattedText($body, $this->format, array('balanced' => $this->hasFlag(self::FLAG_BALANCED)) ); } return $this->_body; } function setBody($body) { global $cfg; if (!$body instanceof ThreadEntryBody) { if ($cfg->isRichTextEnabled()) $body = new HtmlThreadEntryBody($body); else $body = new TextThreadEntryBody($body); } $this->format = $body->getType(); $this->body = (string) $body; return $this->save(); } function getMessage() { return $this->getBody(); } function getCreateDate() { return $this->created; } function getUpdateDate() { return $this->updated; } function getNumAttachments() { return $this->attachments->count(); } function getEmailMessageId() { if ($this->email_info) return $this->email_info->mid; } function getEmailHeaderArray() { require_once(INCLUDE_DIR.'class.mailparse.php'); if (!isset($this->_headers) && $this->email_info && isset($this->email_info->headers) ) { $this->_headers = Mail_Parse::splitHeaders($this->email_info->headers); } return $this->_headers; } function getEmailReferences($include_mid=true) { $references = ''; $headers = self::getEmailHeaderArray(); if (isset($headers['References']) && $headers['References']) $references = $headers['References']." "; if ($include_mid && ($mid = $this->getEmailMessageId())) $references .= $mid; return $references; } /** * Retrieve a list of all the recients of this message if the message * was received via email. * * Returns: * (array) list of recipients parsed with the Mail/RFC822 * address parsing utility. Returns an empty array if the message was * not received via email. */ function getAllEmailRecipients() { $headers = self::getEmailHeaderArray(); $recipients = array(); if (!$headers) return $recipients; foreach (array('To', 'Cc') as $H) { if (!isset($headers[$H])) continue; if (!($all = Mail_Parse::parseAddressList($headers[$H]))) continue; $recipients = array_merge($recipients, $all); } return $recipients; } /** * Recurse through the ancestry of this thread entry to find the first * thread entry which cites a email Message-ID field. * * Returns: * or null if neither this thread entry nor any of its * ancestry contains an email header with an email Message-ID header. */ function findOriginalEmailMessage() { $P = $this; while (!$P->getEmailMessageId() && ($P = $P->getParent())); return $P; } function getUIDFromEmailReference($ref) { $info = unpack('Vtid/Vuid', Base32::decode(strtolower(substr($ref, -13)))); if ($info && $info['tid'] == $this->getId()) return $info['uid']; } function getThreadId() { return $this->thread_id; } function getThread() { if (!isset($this->_thread) && $this->thread_id) // TODO: Consider typing the thread based on its type field $this->_thread = ObjectThread::lookup($this->getThreadId()); return $this->_thread; } function getStaffId() { return isset($this->staff_id) ? $this->staff_id : 0; } function getStaff() { return $this->staff; } function getUserId() { return isset($this->user_id) ? $this->user_id : 0; } function getUser() { return $this->user; } function getEditor() { static $types = array( 'U' => 'User', 'S' => 'Staff', ); if (!isset($types[$this->editor_type])) return null; return $types[$this->editor_type]::lookup($this->editor); } function getName() { if ($this->staff_id) return $this->staff->getName(); if ($this->user_id) return $this->user->getName(); return $this->poster; } function getEmailHeader() { if ($this->email_info) return $this->email_info->headers; } function isAutoReply() { if (!isset($this->is_autoreply)) $this->is_autoreply = $this->getEmailHeaderArray() ? TicketFilter::isAutoReply($this->getEmailHeaderArray()) : false; return $this->is_autoreply; } function isBounce() { if (!isset($this->is_bounce)) $this->is_bounce = $this->getEmailHeaderArray() ? TicketFilter::isBounce($this->getEmailHeaderArray()) : false; return $this->is_bounce; } function isBounceOrAutoReply() { return ($this->isAutoReply() || $this->isBounce()); } function hasFlag($flag) { return ($this->get('flags', 0) & $flag) != 0; } function clearFlag($flag) { return $this->set('flags', $this->get('flags') & ~$flag); } function setFlag($flag) { return $this->set('flags', $this->get('flags') | $flag); } function isSystem() { return $this->hasFlag(self::FLAG_SYSTEM); } protected function normalizeFileInfo($files, $add_error=true) { static $error_descriptions = array( UPLOAD_ERR_INI_SIZE => /* @trans */ 'File is too large', UPLOAD_ERR_FORM_SIZE => /* @trans */ 'File is too large', UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded.', UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.', UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.', UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.', ); if (!is_array($files)) $files = array($files); $ids = array(); foreach ($files as $id => $info) { $F = array('inline' => is_array($info) && @$info['inline']); $AF = null; if ($info instanceof AttachmentFile) $fileId = $info->getId(); elseif (is_array($info) && isset($info['id'])) $fileId = $info['id']; elseif ($AF = AttachmentFile::create($info)) $fileId = $AF->getId(); elseif ($add_error) { $error = $info['error'] ?: sprintf(_S('Unable to save attachment - %s'), $info['name'] ?: $info['id']); if (is_numeric($error) && isset($error_descriptions[$error])) { $error = sprintf(_S('Error #%1$d: %2$s'), $error, _S($error_descriptions[$error])); } // No need to log the missing-file error number if ($error != UPLOAD_ERR_NO_FILE && ($thread = $this->getThread()) ) { // Log to the thread directly, since alerts should be // suppressed and this is defintely a system message $thread->addNote(array( 'title' => _S('File Import Error'), 'note' => new TextThreadEntryBody($error), 'poster' => 'SYSTEM', 'staffId' => 0, )); } continue; } $F['id'] = $fileId; if (is_string($info)) $F['name'] = $info; if (isset($AF)) $F['file'] = $AF; // Add things like the `key` field, but don't change current // keys of the file array if (is_array($info)) $F += $info; // Key is required for CID rewriting in the body if (!isset($F['key']) && ($AF = AttachmentFile::lookup($F['id']))) $F['key'] = $AF->key; $ids[] = $F; } return $ids; } /* Save attachment to the DB. @file is a mixed var - can be ID or file hashtable. */ function createAttachment($file, $name=false) { $att = new Attachment(array( 'type' => 'H', 'object_id' => $this->getId(), 'file_id' => $file['id'], 'inline' => $file['inline'] ? 1 : 0, )); // Record varying file names in the attachment record if (is_array($file) && isset($file['name'])) { $filename = $file['name']; } elseif (is_string($name)) { $filename = $name; } if ($filename) { // This should be a noop since the ORM caches on PK $F = @$file['file'] ?: AttachmentFile::lookup($file['id']); // XXX: This is not Unicode safe // TODO: fix name lookup if ($F && strcasecmp($F->name, $filename) !== 0) $att->name = $filename; } if (!$att->save()) return false; return $att; } function createAttachments(array $files) { $attachments = array(); foreach ($files as $info) { if ($A = $this->createAttachment($info, @$info['name'] ?: false)) $attachments[] = $A; } return $attachments; } function getAttachments() { return $this->attachments; } function getAttachmentUrls() { $json = array(); foreach ($this->attachments as $att) { $json[$att->file->getKey()] = array( 'download_url' => $att->file->getDownloadUrl(), 'filename' => $att->getFilename(), ); } return $json; } function getAttachmentsLinks($file='attachment.php', $target='_blank', $separator=' ') { // TODO: Move this to the respective UI templates $str=''; foreach ($this->attachments as $att ) { if ($att->inline) continue; $size = ''; if ($att->file->size) $size=sprintf('(%s)', Format::file_size($att->file->size)); $str .= sprintf( '%s%s %s', $att->file->getDownloadUrl(), $target, Format::htmlchars($att->file->name), $size, $separator); } return $str; } /* save email info * TODO: Refactor it to include outgoing emails on responses. */ function saveEmailInfo($vars) { // Don't save empty message ID if (!$vars || !$vars['mid']) return 0; $this->ht['email_mid'] = $vars['mid']; $header = false; if (isset($vars['header'])) $header = $vars['header']; self::logEmailHeaders($this->getId(), $vars['mid'], $header); } /* static */ function logEmailHeaders($id, $mid, $header=false) { $headerInfo = Mail_Parse::splitHeaders($header); if (!$id || !$mid) return false; $this->email_info = new ThreadEntryEmailInfo(array( 'thread_entry_id' => $id, 'email_id' => Email::getIdByEmail($headerInfo['Delivered-To']), 'mid' => $mid, )); if ($header) $this->email_info->headers = trim($header); return $this->email_info->save(); } function getActivity() { return new ThreadActivity('', ''); } /* variables */ function __toString() { return (string) $this->getBody(); } // TemplateVariable interface function asVar() { return (string) $this->getBody()->display('email'); } function getVar($tag) { switch(strtolower($tag)) { case 'create_date': return new FormattedDate($this->getCreateDate()); case 'update_date': return new FormattedDate($this->getUpdateDate()); case 'files': throw new OOBContent(OOBContent::FILES, $this->attachments->all()); } } static function getVarScope() { return array( 'files' => __('Attached files'), 'body' => __('Message body'), 'create_date' => array( 'class' => 'FormattedDate', 'desc' => __('Date created'), ), 'ip_address' => __('IP address of remote user, for web submissions'), 'poster' => __('Submitter of the thread item'), 'staff' => array( 'class' => 'Staff', 'desc' => __('Agent posting the note or response'), ), 'title' => __('Subject, if any'), 'user' => array( 'class' => 'User', 'desc' => __('User posting the message'), ), ); } /** * Parameters: * mailinfo (hash) email header information. Must include keys * - "mid" => Message-Id header of incoming mail * - "in-reply-to" => Message-Id the email is a direct response to * - "references" => List of Message-Id's the email is in response * - "subject" => Find external ticket number in the subject line * * seen (by-ref:bool) a flag that will be set if the message-id was * positively found, indicating that the message-id has been * previously seen. This is useful if no thread-id is associated * with the email (if it was rejected for instance). */ function lookupByEmailHeaders(&$mailinfo, &$seen=false) { // Search for messages using the References header, then the // in-reply-to header if ($mailinfo['mid'] && ($entry = ThreadEntry::objects() ->filter(array('email_info__mid' => $mailinfo['mid'])) ->order_by(false) ->first() ) ) { $seen = true; if ($mailinfo['system_emails'] && ($t = $entry->getThread()->getObject()) && $t instanceof Ticket) $t->systemReferral($mailinfo['system_emails']); return $entry; } $possibles = array(); foreach (array('mid', 'in-reply-to', 'references') as $header) { $matches = array(); if (!isset($mailinfo[$header]) || !$mailinfo[$header]) continue; // Header may have multiple entries (usually separated by // spaces ( ) elseif (!preg_match_all('/<([^>@]+@[^>]+)>/', $mailinfo[$header], $matches)) continue; // The References header will have the most recent message-id // (parent) on the far right. // @see rfc 1036, section 2.2.5 // @see http://www.jwz.org/doc/threading.html $possibles = array_merge($possibles, array_reverse($matches[1])); } // Add the message id if it is embedded in the body $match = array(); if (preg_match('`(?:class="mid-|Ref-Mid: )([^"\s]*)(?:$|")`', (string) $mailinfo['message'], $match) && !in_array($match[1], $possibles) ) { $possibles[] = $match[1]; } $thread = null; foreach ($possibles as $mid) { // Attempt to detect the ticket and user ids from the // message-id header. If the message originated from // osTicket, the Mailer class can break it apart. If it came // from this help desk, the 'loopback' property will be set // to true. $mid_info = Mailer::decodeMessageId($mid); if (!$mid_info || !$mid_info['loopback']) continue; if (isset($mid_info['uid']) && @$mid_info['entryId'] && ($t = ThreadEntry::lookup($mid_info['entryId'])) && ($t->thread_id == $mid_info['threadId']) ) { if (@$mid_info['userId']) { $mailinfo['userId'] = $mid_info['userId']; $user = User::lookupByEmail($mailinfo['email']); if ($user && $mailinfo['userId'] != $user->getId()) $mailinfo['userId'] = $user->getId(); } elseif (@$mid_info['staffId']) { $mailinfo['staffId'] = $mid_info['staffId']; $staffId = Staff::getIdByEmail($mailinfo['email']); if ($staffId && $mailinfo['staffId'] != $staffId) $mailinfo['staffId'] = $staffId; } // Capture the user type if (@$mid_info['userClass']) $mailinfo['userClass'] = $mid_info['userClass']; // ThreadEntry was positively identified return $t; } } // Passive threading - listen mode if (count($possibles) && ($entry = ThreadEntry::objects() ->filter(array('email_info__mid__in' => array_map( function ($a) { return "<$a>"; }, $possibles))) ->first() ) ) { $mailinfo['passive'] = true; return $entry; } // Search for ticket by the [#123456] in the subject line // This is the last resort - emails must match to avoid message // injection by third-party. $subject = $mailinfo['subject']; $match = array(); if ($subject && $mailinfo['email'] // Required `#` followed by one or more of // punctuation (-) then letters, numbers, and symbols // (Try not to match closing punctuation (`]`) in [#12345]) && preg_match("/#((\p{P}*[^\p{C}\p{Z}\p{P}]+)+)/u", $subject, $match) //Lookup by ticket number && ($ticket = Ticket::lookupByNumber($match[1])) //Lookup the user using the email address && ($user = User::lookup(array('emails__address' => $mailinfo['email'])))) { //We have a valid ticket and user if ($ticket->getUserId() == $user->getId() //owner || ($c = Collaborator::lookup( // check if collaborator array('user_id' => $user->getId(), 'thread_id' => $ticket->getThreadId())))) { $mailinfo['userId'] = $user->getId(); return $ticket->getLastMessage(); } } return null; } /** * Find a thread entry from a message-id created from the * ::asMessageId() method. * * *DEPRECATED* use Mailer::decodeMessageId() instead */ function lookupByRefMessageId($mid, $from) { global $ost; $mid = trim($mid, '<>'); list($ver, $ids, $mails) = explode('$', $mid, 3); // Current version is if ($ver !== '') return false; $ids = @unpack('Vthread', base64_decode($ids)); if (!$ids || !$ids['thread']) return false; $entry = ThreadEntry::lookup($ids['thread']); if (!$entry) return false; // Compute the value to be compared from $mails (which used to be in // ThreadEntry::asMessageId() (#nolint) $domain = md5($ost->getConfig()->getURL()); $ticket = $entry->getThread()->getObject(); if (!$ticket instanceof Ticket) return false; $check = sprintf('%s@%s', substr(md5($from . $ticket->getNumber() . $ticket->getId()), -10), substr($domain, -10) ); if ($check != $mails) return false; return $entry; } function setExtra($entries, $info=NULL, $thread_id=NULL) { foreach ($entries as $entry) { $mergeInfo = ThreadEntryMergeInfo::objects() ->filter(array('thread_entry_id'=>$entry->getId())) ->values_flat('thread_entry_id') ->first(); if (!$mergeInfo) { $mergeInfo = new ThreadEntryMergeInfo(array( 'thread_entry_id' => $entry->getId(), 'data' => json_encode($info), )); $mergeInfo->save(); } $entry->saveExtra($info, $thread_id); } } function saveExtra($info=NULL, $thread_id=NULL) { $this->setFlag(ThreadEntry::FLAG_CHILD, true); $this->thread_id = $thread_id; $this->save(); } function getMergeData() { return $this->merge_info ? $this->merge_info->data : null; } function sortEntries($entries, $ticket) { $buckets = array(); $childEntries = array(); foreach ($entries as $i=>$E) { if ($ticket) { $extra = json_decode($E->getMergeData(), true); //separated entries if ($ticket->getMergeType() == 'separate') { if ($extra['thread']) { $childEntries[$E->getId()] = $E; if ($childEntries) { uasort($childEntries, function ($a, $b) { //sort by child ticket $aExtra = json_decode($a->getMergeData(), true); $bExtra = json_decode($b->getMergeData(), true); if ($aExtra['thread'] != $bExtra["thread"]) return $bExtra["thread"] - $aExtra['thread']; }); uasort($childEntries, function($a, $b) { //sort by child created date $aExtra = json_decode($a->getMergeData(), true); $bExtra = json_decode($b->getMergeData(), true); if ($aExtra['thread'] == $bExtra["thread"]) return strtotime($a->created) - strtotime($b->created); }); } } else $buckets[$E->getId()] = $E; } else $buckets[$E->getId()] = $E; } else //we may be looking at a task $buckets[$E->getId()] = $E; } if ($ticket && $ticket->getMergeType() == 'separate') $buckets = $buckets + $childEntries; return $buckets; } //new entry ... we're trusting the caller to check validity of the data. static function create($vars=false) { global $cfg; assert(is_array($vars)); //Must have... if (!$vars['threadId'] || !$vars['type']) return false; if (!$vars['body'] instanceof ThreadEntryBody) { if ($cfg->isRichTextEnabled()) $vars['body'] = new HtmlThreadEntryBody($vars['body']); else $vars['body'] = new TextThreadEntryBody($vars['body']); } if (!($body = Format::strip_emoticons($vars['body']->getClean()))) $body = '-'; //Special tag used to signify empty message as stored. // Ensure valid external images $body = Format::stripExternalImages($body); $poster = $vars['poster']; if ($poster && is_object($poster)) $poster = (string) $poster; $entry = new static(array( 'created' => SqlFunction::NOW(), 'type' => $vars['type'], 'thread_id' => $vars['threadId'], 'title' => Format::strip_emoticons(Format::sanitize($vars['title'], true)), 'format' => $vars['body']->getType(), 'staff_id' => $vars['staffId'], 'user_id' => $vars['userId'], 'poster' => $poster, 'source' => $vars['source'], 'flags' => $vars['flags'] ?: 0, )); //add recipients to thread entry if ($vars['thread_entry_recipients']) { $count = 0; foreach ($vars['thread_entry_recipients'] as $key => $value) $count = $count + count($value); if ($count > 1) $entry->flags |= ThreadEntry::FLAG_REPLY_ALL; else $entry->flags |= ThreadEntry::FLAG_REPLY_USER; $entry->recipients = json_encode($vars['thread_entry_recipients']); } if (Collaborator::getIdByUserId($vars['userId'], $vars['threadId'])) $entry->flags |= ThreadEntry::FLAG_COLLABORATOR; if ($entry->format == 'html') // The current codebase properly balances html $entry->flags |= self::FLAG_BALANCED; // Flag system messages if (!($vars['staffId'] || $vars['userId'])) $entry->flags |= self::FLAG_SYSTEM; if (isset($vars['pid'])) $entry->pid = $vars['pid']; // Check if 'reply_to' is in the $vars as the previous ThreadEntry // instance. If the body of the previous message is found in the new // body, strip it out. elseif (isset($vars['reply_to']) && $vars['reply_to'] instanceof ThreadEntry) $entry->pid = $vars['reply_to']->getId(); if ($vars['ip_address']) $entry->ip_address = $vars['ip_address']; /************* ATTACHMENTS *****************/ // Drop stripped email inline images if ($vars['attachments']) { foreach ($vars['body']->getStrippedImages() as $cid) { foreach ($vars['attachments'] as $i=>$a) { if (@$a['cid'] && $a['cid'] == $cid) { // Inline referenced attachment was stripped unset($vars['attachments'][$i]); } } } } // Handle extracted embedded images (). // The extraction has already been performed in the ThreadEntryBody // class. Here they should simply be added to the attachments list if ($atts = $vars['body']->getEmbeddedHtmlImages()) { if (!is_array($vars['attachments'])) $vars['attachments'] = array(); foreach ($atts as $info) { $vars['attachments'][] = $info; } } $attached_files = array(); foreach (array( // Web uploads and canned attachments $vars['files'], // Emailed or API attachments $vars['attachments'], // Inline images (attached to the draft) Draft::getAttachmentIds($body), ) as $files ) { if (is_array($files)) { // Detect *inline* email attachments foreach ($files as $i=>$a) { if (isset($a['cid']) && $a['cid'] && strpos($body, 'cid:'.$a['cid']) !== false) $files[$i]['inline'] = true; } foreach ($entry->normalizeFileInfo($files) as $F) { // Deduplicate on the `key` attribute. The key is // necessary for the CID rewrite below $attached_files[$F['key']] = $F; } } } // Change inside the message to point to a unique // hash-code for the attachment. Since the content-id will be // discarded, only the unique hash-code (key) will be available to // retrieve the image later foreach ($attached_files as $key => $a) { if (isset($a['cid']) && $a['cid']) { $body = preg_replace('/src=("|\'|\b)(?:cid:)?' . preg_quote($a['cid'], '/').'\1/i', 'src="cid:'.$key.'"', $body); } } // Set body here after it was rewritten to capture the stored file // keys (above) // Store body as an attachment if bigger than allowed packet size if (mb_strlen($body) >= 65000) { // 65,535 chars in text field. $entry->body = NULL; $file = array( 'type' => 'text/html', 'name' => md5($body).'.txt', 'data' => $body, ); if (($AF = AttachmentFile::create($file))) { $attached_files[$file['key']] = array( 'id' => $AF->getId(), 'inline' => true, 'file' => $AF); } else { $entry->body = $body; } } else { $entry->body = $body; } if (!$entry->save(true)) return false; // Associate the attached files with this new entry $entry->createAttachments($attached_files); // Save mail message id, if available $entry->saveEmailInfo($vars); Signal::send('threadentry.created', $entry); return $entry; } static function add($vars, &$errors=array()) { return self::create($vars); } // Extensible thread entry actions ------------------------ /** * getActions * * Retrieve a list of possible actions. This list is shown to the agent * via drop-down list at the top-right of the thread entry when rendered * in the UI. */ function getActions() { if (!isset($this->_actions)) { $this->_actions = array(); foreach (self::$action_registry as $group=>$list) { $T = array(); $this->_actions[__($group)] = &$T; foreach ($list as $id=>$action) { $A = new $action($this); if ($A->isVisible()) { $T[$id] = $A; } } unset($T); } } return $this->_actions; } function hasActions() { foreach ($this->getActions() as $group => $list) { if (count($list)) return true; } return false; } function triggerAction($name) { foreach ($this->getActions() as $group=>$list) { foreach ($list as $id=>$action) { if (0 === strcasecmp($id, $name)) { if (!$action->isEnabled()) return false; $action->trigger(); return true; } } } return false; } static $action_registry = array(); static function registerAction($group, $action) { if (!isset(self::$action_registry[$group])) self::$action_registry[$group] = array(); self::$action_registry[$group][$action::getId()] = $action; } static function getPermissions() { return self::$perms; } static function getTypes() { return self::$types; } } RolePermission::register(/* @trans */ 'Tickets', ThreadEntry::getPermissions()); class ThreadReferral extends VerySimpleModel { static $meta = array( 'table' => THREAD_REFERRAL_TABLE, 'pk' => array('id'), 'joins' => array( 'thread' => array( 'constraint' => array('thread_id' => 'Thread.id'), ), 'agent' => array( 'constraint' => array( 'object_type' => "'S'", 'object_id' => 'Staff.staff_id', ), ), 'team' => array( 'constraint' => array( 'object_type' => "'E'", 'object_id' => 'Team.team_id', ), ), 'dept' => array( 'constraint' => array( 'object_type' => "'D'", 'object_id' => 'Dept.id', ), ), ) ); var $icons = array( 'E' => 'group', 'D' => 'sitemap', 'S' => 'user' ); var $_object = null; function getId() { return $this->id; } function getName() { return (string) $this->getObject(); } function getObject() { if (!isset($this->_object)) { $this->_object = ObjectModel::lookup( $this->object_id, $this->object_type); } return $this->_object; } function getIcon() { return $this->icons[$this->object_type]; } function display() { return sprintf(' %s', $this->getIcon(), $this->getName()); } static function create($vars) { $new = new self($vars); $new->created = SqlFunction::NOW(); return $new->save(); } } class ThreadEvent extends VerySimpleModel { static $meta = array( 'table' => THREAD_EVENT_TABLE, 'pk' => array('id'), 'joins' => array( // Originator of activity 'agent' => array( 'constraint' => array( 'uid' => 'Staff.staff_id', ), 'null' => true, ), // Agent assignee 'staff' => array( 'constraint' => array( 'staff_id' => 'Staff.staff_id', ), 'null' => true, ), 'team' => array( 'constraint' => array( 'team_id' => 'Team.team_id', ), 'null' => true, ), 'thread' => array( 'constraint' => array('thread_id' => 'Thread.id'), ), 'user' => array( 'constraint' => array( 'uid' => 'User.id', ), 'null' => true, ), 'dept' => array( 'constraint' => array( 'dept_id' => 'Dept.id', ), 'null' => true, ), 'topic' => array( 'constraint' => array( 'topic_id' => 'Topic.topic_id', ), 'null' => true, ), 'event' => array( 'constraint' => array( 'event_id' => 'Event.id', ), 'null' => true, ), ), ); // Valid events for database storage const ASSIGNED = 'assigned'; const RELEASED = 'released'; const CLOSED = 'closed'; const CREATED = 'created'; const COLLAB = 'collab'; const EDITED = 'edited'; const ERROR = 'error'; const OVERDUE = 'overdue'; const REOPENED = 'reopened'; const STATUS = 'status'; const TRANSFERRED = 'transferred'; const REFERRED = 'referred'; const VIEWED = 'viewed'; const MERGED = 'merged'; const UNLINKED = 'unlinked'; const MODE_STAFF = 1; const MODE_CLIENT = 2; var $_data; function getAvatar($size=null) { if ($this->uid && $this->uid_type == 'S') return $this->agent ? $this->agent->getAvatar($size) : ''; if ($this->uid && $this->uid_type == 'U') return $this->user ? $this->user->getAvatar($size) : ''; } function getUserName() { if ($this->uid && $this->uid_type == 'S') return $this->agent ? $this->agent->getName() : $this->username; if ($this->uid && $this->uid_type == 'U') return $this->user ? $this->user->getName() : $this->username; return $this->username; } function getIcon() { $icons = array( 'assigned' => 'hand-right', 'released' => 'unlock', 'collab' => 'group', 'created' => 'magic', 'overdue' => 'time', 'transferred' => 'share-alt', 'referred' => 'exchange', 'edited' => 'pencil', 'closed' => 'thumbs-up-alt', 'reopened' => 'rotate-right', 'resent' => 'reply-all icon-flip-horizontal', 'merged' => 'code-fork', 'linked' => 'link', 'unlinked' => 'unlink', ); return @$icons[$this->state] ?: 'chevron-sign-right'; } function getDescription($mode=self::MODE_STAFF) { // Abstract description return $this->template(sprintf( __('%s by {somebody} {timestamp}'), $this->state ), $mode); } function template($description, $mode=self::MODE_STAFF) { global $thisstaff, $cfg; $self = $this; $hideName = $cfg->hideStaffName(); return preg_replace_callback('/\{(<(?P([^>]+))>)?(?P[^}.]+)(\.(?P[^}]+))?\}/', function ($m) use ($self, $thisstaff, $cfg, $hideName, $mode) { switch ($m['key']) { case 'assignees': $assignees = array(); if ($S = $self->staff) { $avatar = ''; if ($cfg->isAvatarsEnabled()) $avatar = $S->getAvatar(); $assignees[] = $avatar.$S->getName(); } if ($T = $self->team) { $assignees[] = $T->getLocalName(); } return implode('/', $assignees); case 'somebody': if ($hideName && $self->agent && $mode == self::MODE_CLIENT) $name = __('Staff'); else $name = $self->getUserName(); if ($cfg->isAvatarsEnabled() && ($avatar = $self->getAvatar())) $name = $avatar.$name; return $name; case 'timestamp': $timeFormat = null; if ($mode != self::MODE_CLIENT && $thisstaff && !strcasecmp($thisstaff->datetime_format, 'relative')) { $timeFormat = function ($timestamp) { return Format::relativeTime(Misc::db2gmtime($timestamp)); }; } return sprintf('', $timeFormat ? 'class="relative"' : '', date(DateTime::W3C, Misc::db2gmtime($self->timestamp)), Format::daydatetime($self->timestamp), $timeFormat ? $timeFormat($self->timestamp) : Format::datetime($self->timestamp) ); case 'agent': $name = $self->agent->getName(); if ($cfg->isAvatarsEnabled() && ($avatar = $self->getAvatar())) $name = $avatar.$name; return $name; case 'dept': if ($dept = $self->getDept()) return $dept->getLocalName(); return __('None'); case 'data': $val = $self->getData($m['data']); if (is_array($val)) list($val, $fallback) = $val; if ($m['type'] && class_exists($m['type'])) $val = $m['type']::lookup($val); if (!$val && $fallback) $val = $fallback; return Format::htmlchars((string) $val); } return $m[0]; }, $description ); } function getDept() { return $this->dept; } function getData($key=false) { if (!isset($this->_data)) $this->_data = JsonDataParser::decode($this->data); return ($key) ? @$this->_data[$key] : $this->_data; } function render($mode) { $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR; $event = $this->getTypedEvent(); include $inc . 'templates/thread-event.tmpl.php'; } static function create($ht=false, $user=false) { $inst = new static($ht); $inst->timestamp = SqlFunction::NOW(); global $thisstaff, $thisclient; $user = is_object($user) ? $user : $thisstaff ?: $thisclient; if ($user instanceof Staff) { $inst->uid_type = 'S'; $inst->uid = $user->getId(); } elseif ($user instanceof User) { $inst->uid_type = 'U'; $inst->uid = $user->getId(); } return $inst; } static function forTicket($ticket, $state, $user=false) { global $thisstaff; if($thisstaff && !$ticket->getStaffId()) $staff = $thisstaff->getId(); else $staff = $ticket->getStaffId(); $inst = self::create(array( 'thread_type' => ObjectModel::OBJECT_TYPE_TICKET, 'staff_id' => $staff, 'team_id' => $ticket->getTeamId(), 'dept_id' => $ticket->getDeptId(), 'topic_id' => $ticket->getTopicId(), ), $user); return $inst; } static function forTask($task, $state, $user=false) { $inst = self::create(array( 'thread_type' => ObjectModel::OBJECT_TYPE_TASK, 'staff_id' => $task->getStaffId(), 'team_id' => $task->getTeamId(), 'dept_id' => $task->getDeptId(), ), $user); return $inst; } function getTypedEvent() { static $subclasses; if (!isset($subclasses)) { $parent = get_class($this); $subclasses = array(); foreach (get_declared_classes() as $class) { if (is_subclass_of($class, $parent)) $subclasses[$class::$state] = $class; } } $this->state = Event::getNameById($this->event_id); if (!($class = $subclasses[$this->state])) return $this; return new $class($this->ht); } } class Event extends VerySimpleModel { static $meta = array( 'table' => EVENT_TABLE, 'pk' => array('id'), ); function getInfo() { return $this->ht; } function getId() { return $this->id; } function getName() { return $this->name; } function getDescription() { return $this->description; } static function getNameById($id) { return array_search($id, self::getIds()); } static function getIdByName($name) { $ids = self::getIds(); return $ids[$name] ?: 0; } static function getIds() { static $ids; if (!isset($ids)) { $ids = array(); $events = self::objects()->values_flat('id', 'name'); foreach ($events as $row) { list($id, $name) = $row; $ids[$name] = $id; } } return $ids; } static function getStates($dropdown=false) { $names = array(); if ($dropdown) $names = array(__('All')); $events = self::objects()->values_flat('name'); foreach ($events as $val) $names[] = ucfirst($val[0]); return $names; } static function create($vars=false, &$errors=array()) { $event = new static($vars); return $event; } static function __create($vars, &$errors=array()) { $event = self::create($vars); $event->save(); return $event; } function save($refetch=false) { return parent::save($refetch); } } class ThreadEvents extends InstrumentedList { function annul($event) { $event_id = Event::getIdByName($event); $this->queryset ->filter(array('event_id' => $event_id)) ->update(array('annulled' => 1)); } /** * Add an event to the thread activity log. * * Parameters: * $object - Object to log activity for * $state - State name of the activity (one of 'created', 'edited', * 'deleted', 'closed', 'reopened', 'error', 'collab', 'resent', * 'assigned', 'released', 'transferred') * $data - (array?) Details about the state change * $user - (string|User|Staff) user triggering the state change * $annul - (state) a corresponding state change that is annulled by * this event */ function log($object, $state, $data=null, $user=null, $annul=null) { global $thisstaff, $thisclient; if ($object && ($object instanceof Ticket)) // TODO: Use $object->createEvent() (nolint) $event = ThreadEvent::forTicket($object, $state, $user); elseif ($object && ($object instanceof Task)) $event = ThreadEvent::forTask($object, $state, $user); if (is_null($event)) return; # Annul previous entries if requested (for instance, reopening a # ticket will annul an 'closed' entry). This will be useful to # easily prevent repeated statistics. if ($annul) { $this->annul($annul); } $username = $user; $user = is_object($user) ? $user : $thisclient ?: $thisstaff; if (!is_string($username)) { if ($user instanceof Staff) { $username = $user->getUserName(); } // XXX: Use $user here elseif ($thisclient) { if ($thisclient->hasAccount()) $username = $thisclient->getFullName(); if (!$username) $username = $thisclient->getEmail(); } else { # XXX: Security Violation ? $username = 'SYSTEM'; } } $event->username = $username; $event->event_id = Event::getIdByName($state); if ($data) { if (is_array($data)) $data = JsonDataEncoder::encode($data); if (!is_string($data)) throw new InvalidArgumentException('Data must be string or array'); $event->data = $data; } $this->add($event); // Save event immediately return $event->save(); } } class AssignmentEvent extends ThreadEvent { static $icon = 'hand-right'; static $state = 'assigned'; function getDescription($mode=self::MODE_STAFF) { $data = $this->getData(); switch (true) { case !is_array($data): default: $desc = __('Assignee changed by {somebody} to {assignees} {timestamp}'); break; case isset($data['staff']): $desc = __('{somebody} assigned this to {data.staff} {timestamp}'); break; case isset($data['team']): $desc = __('{somebody} assigned this to {data.team} {timestamp}'); break; case isset($data['claim']): $desc = __('{somebody} claimed this {timestamp}'); break; } return $this->template($desc, $mode); } } class ReleaseEvent extends ThreadEvent { static $icon = 'unlock'; static $state = 'released'; function getDescription($mode=self::MODE_STAFF) { $data = $this->getData(); switch (true) { case isset($data['staff'], $data['team']): $desc = __('Ticket released from {data.team} and {data.staff} by {somebody} {timestamp}'); break; case isset($data['staff']): $desc = __('Ticket released from {data.staff} by {somebody} {timestamp}'); break; case isset($data['team']): $desc = __('Ticket released from {data.team} by {somebody} {timestamp}'); break; default: $desc = __('{somebody} released ticket assignment {timestamp}'); break; } return $this->template($desc, $mode); } } class ReferralEvent extends ThreadEvent { static $icon = 'exchange'; static $state = 'referred'; function getDescription($mode=self::MODE_STAFF) { $data = $this->getData(); switch (true) { case isset($data['staff']): $desc = __('{somebody} referred this to {data.staff} {timestamp}'); break; case isset($data['team']): $desc = __('{somebody} referred this to {data.team} {timestamp}'); break; case isset($data['dept']): $desc = __('{somebody} referred this to {data.dept} {timestamp}'); break; } return $this->template($desc, $mode); } } class CloseEvent extends ThreadEvent { static $icon = 'thumbs-up-alt'; static $state = 'closed'; function getDescription($mode=self::MODE_STAFF) { if ($this->getData('status')) return $this->template(__('Closed by {somebody} with status of {data.status} {timestamp}'), $mode); else return $this->template(__('Closed by {somebody} {timestamp}'), $mode); } } class CollaboratorEvent extends ThreadEvent { static $icon = 'group'; static $state = 'collab'; function getDescription($mode=self::MODE_STAFF) { $data = $this->getData(); switch (true) { case isset($data['org']): $desc = __('Collaborators for {data.org} organization added'); break; case isset($data['del']): $base = __('{somebody} removed %s from the collaborators {timestamp}'); $collabs = array(); $users = User::objects()->filter(array('id__in' => array_keys($data['del']))); foreach ($data['del'] as $id=>$c) { $U = false; foreach ($users as $user) { if ($user->id == $id) { $U = $user; break; } } $collabs[] = Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c); } $desc = sprintf($base, implode(', ', $collabs)); break; case isset($data['add']): $base = __('{somebody} added %s as collaborators {timestamp}'); $collabs = array(); if ($data['add']) { $users = User::objects()->filter(array('id__in' => array_keys($data['add']))); foreach ($data['add'] as $id=>$c) { $U = false; foreach ($users as $user) { if ($user->id == $id) { $U = $user; break; } } $c = sprintf("%s %s", Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c), $c['src'] ? sprintf(__('via %s' /* e.g. "Added collab "Me " via Email (to)" */ ), $c['src']) : '' ); $collabs[] = $c; } } $desc = $collabs ? sprintf($base, implode(', ', $collabs)) : 'somebody'; break; } return $this->template($desc, $mode); } } class CreationEvent extends ThreadEvent { static $icon = 'magic'; static $state = 'created'; function getDescription($mode=self::MODE_STAFF) { return $this->template(__('Created by {somebody} {timestamp}'), $mode); } } class EditEvent extends ThreadEvent { static $icon = 'pencil'; static $state = 'edited'; function getDescription($mode=self::MODE_STAFF) { $data = $this->getData(); switch (true) { case isset($data['owner']): $desc = __('{somebody} changed ownership to {data.owner} {timestamp}'); break; case isset($data['status']): $desc = __('{somebody} changed the status to {data.status} {timestamp}'); break; case isset($data['fields']): $fields = $changes = array(); foreach (DynamicFormField::objects()->filter(array( 'id__in' => array_keys($data['fields']) )) as $F) { $fields[$F->id] = $F; } foreach ($data['fields'] as $id=>$f) { if (!($field = $fields[$id])) continue; if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers()) continue; list($old, $new) = $f; $impl = $field->getImpl($field); $before = $impl->to_php($old); $after = $impl->to_php($new); $changes[] = sprintf('%s %s', $field->getLocal('label'), $impl->whatChanged($before, $after)); } // Fallthrough to other editable fields case isset($data['topic_id']): case isset($data['sla_id']): case isset($data['source']): case isset($data['user_id']): case isset($data['duedate']): $base = __('Updated by {somebody} {timestamp} — %s'); foreach (array( 'topic_id' => array(__('Help Topic'), array('Topic', 'getTopicName')), 'sla_id' => array(__('SLA'), array('SLA', 'getSLAName')), 'duedate' => array(__('Due Date'), array('Format', 'date')), 'user_id' => array(__('Ticket Owner'), array('User', 'getNameById')), 'source' => array(__('Source'), null) ) as $f => $info) { if (isset($data[$f])) { list($name, $desc) = $info; list($old, $new) = $data[$f]; if ($desc && is_callable($desc)) { $new = call_user_func($desc, $new); if ($old) $old = call_user_func($desc, $old); } if ($old and $new) { $changes[] = sprintf( __('%1$s changed from %2$s to %3$s'), Format::htmlchars($name), Format::htmlchars($old), Format::htmlchars($new) ); } elseif ($new) { $changes[] = sprintf( __('%1$s set to %2$s'), Format::htmlchars($name), Format::htmlchars($new) ); } else { $changes[] = sprintf( __('unset %1$s'), Format::htmlchars($name) ); } } } $desc = $changes ? sprintf($base, implode(', ', $changes)) : ''; break; } return $this->template($desc, $mode); } } class OverdueEvent extends ThreadEvent { static $icon = 'time'; static $state = 'overdue'; function getDescription($mode=self::MODE_STAFF) { return $this->template(__('Flagged as overdue by the system {timestamp}'), $mode); } } class ReopenEvent extends ThreadEvent { static $icon = 'rotate-right'; static $state = 'reopened'; function getDescription($mode=self::MODE_STAFF) { return $this->template(__('Reopened by {somebody} {timestamp}'), $mode); } } class ResendEvent extends ThreadEvent { static $icon = 'reply-all icon-flip-horizontal'; static $state = 'resent'; function getDescription($mode=self::MODE_STAFF) { return $this->template(__('{somebody} resent a previous response {timestamp}'), $mode); } } class TransferEvent extends ThreadEvent { static $icon = 'share-alt'; static $state = 'transferred'; function getDescription($mode=self::MODE_STAFF) { return $this->template(__('{somebody} transferred this to {dept} {timestamp}'), $mode); } } class ViewEvent extends ThreadEvent { static $state = 'viewed'; } class MergedEvent extends ThreadEvent { static $icon = 'code-fork'; static $state = 'merged'; function getDescription($mode=self::MODE_STAFF) { return sprintf($this->template(__('{somebody} merged this ticket with %s{data.id}%s{data.ticket}%s {timestamp}'), $mode), '', ''); } } class LinkedEvent extends ThreadEvent { static $icon = 'link'; static $state = 'linked'; function getDescription($mode=self::MODE_STAFF) { return sprintf($this->template(__('{somebody} linked this ticket with %s{data.id}%s{data.ticket}%s {timestamp}'), $mode), '', ''); } } class UnlinkEvent extends ThreadEvent { static $icon = 'unlink'; static $state = 'unlinked'; function getDescription($mode=self::MODE_STAFF) { return sprintf($this->template(__('{somebody} unlinked this ticket from %s{data.id}%s{data.ticket}%s {timestamp}'), $mode), '', ''); } } class ThreadEntryBody /* extends SplString */ { static $types = array('text', 'html'); var $body; var $type; var $stripped_images = array(); var $embedded_images = array(); var $options = array( 'strip-embedded' => true ); function __construct($body, $type='text', $options=array()) { $type = strtolower($type); if (!in_array($type, static::$types)) throw new Exception("$type: Unsupported ThreadEntryBody type"); $this->body = (string) $body; if (strlen($this->body) > 250000) { $max_packet = db_get_variable('max_allowed_packet', 'global'); // Truncate just short of the max_allowed_packet $this->body = substr($this->body, 0, $max_packet - 2048) . ' ... ' . _S('(truncated)'); } $this->type = $type; $this->options = array_merge($this->options, $options); } function isEmpty() { return !$this->body || $this->body == '-'; } function convertTo($type) { if ($type === $this->type) return $this; $conv = $this->type . ':' . strtolower($type); switch ($conv) { case 'text:html': return new ThreadEntryBody(sprintf('
%s
', Format::htmlchars($this->body)), $type); case 'html:text': return new ThreadEntryBody(Format::html2text((string) $this), $type); } } function stripQuotedReply($tag) { //Strip quoted reply...on emailed messages if (!$tag || strpos($this->body, $tag) === false) return; // Capture a list of inline images $images_before = $images_after = array(); preg_match_all('/src=("|\'|\b)cid:(\S+)\1/', $this->body, $images_before, PREG_PATTERN_ORDER); // Strip the quoted part of the body if ((list($msg) = explode($tag, $this->body, 2)) && trim($msg)) { $this->body = $msg; // Capture a list of dropped inline images if ($images_before) { preg_match_all('/src=("|\'|\b)cid:(\S+)\1/', $this->body, $images_after, PREG_PATTERN_ORDER); $this->stripped_images = array_diff($images_before[2], $images_after[2]); } } } function getStrippedImages() { return $this->stripped_images; } function getEmbeddedHtmlImages() { return $this->embedded_images; } function getType() { return $this->type; } function getClean() { switch ($this->type) { case 'html': return trim($this->body, " <>br/\t\n\r") ? $this->body: ''; case 'text': return trim($this->body) ? $this->body: ''; default: return trim($this->body); } } function __toString() { return (string) $this->body; } function toHtml() { return $this->display('html'); } function prepend($what) { $this->body = $what . $this->body; } function append($what) { $this->body .= $what; } function asVar() { // Email template, assume HTML return $this->display('email'); } function display($format=false) { throw new Exception('display: Abstract display() method not implemented'); } function getSearchable() { return Format::searchable($this->body); } static function fromFormattedText($text, $format=false, $options=array()) { switch ($format) { case 'text': return new TextThreadEntryBody($text); case 'html': return new HtmlThreadEntryBody($text, array('strip-embedded'=>false) + $options); default: return new ThreadEntryBody($text); } } static function clean($text, $format=null) { global $cfg; $format = $format ?: ($cfg->isRichTextEnabled() ? 'html' : 'text'); $body = static::fromFormattedText($text, $format); return $body->getClean(); } } class TextThreadEntryBody extends ThreadEntryBody { function __construct($body, $options=array()) { parent::__construct($body, 'text', $options); } function getClean() { return Format::htmlchars(Format::html_balance(Format::stripEmptyLines(parent::getClean()))); } function prepend($what) { $this->body = $what . "\n\n" . $this->body; } function display($output=false) { if ($this->isEmpty()) return '(empty)'; $escaped = Format::htmlchars($this->body); switch ($output) { case 'html': return '
' .Format::clickableurls($escaped).'
'; case 'email': return '
' .$escaped.'
'; case 'pdf': return nl2br($escaped); default: return '
'.$escaped.'
'; } } } class HtmlThreadEntryBody extends ThreadEntryBody { function __construct($body, $options=array()) { if (!isset($options['strip-embedded']) || $options['strip-embedded']) $body = $this->extractEmbeddedHtmlImages($body); parent::__construct($body, 'html', $options); } function extractEmbeddedHtmlImages($body) { $self = $this; return preg_replace_callback('/src="(data:[^"]+)"/', function ($m) use ($self) { $info = Format::parseRfc2397($m[1], false, false); $info['cid'] = 'img'.Misc::randCode(12); list(,$type) = explode('/', $info['type'], 2); $info['name'] = 'image'.Misc::randCode(4).'.'.$type; $self->embedded_images[] = $info; return 'src="cid:'.$info['cid'].'"'; }, $body); } function getClean() { global $thisclient, $thisstaff; $clean = ($thisstaff || $thisclient) ? Format::editor_spacing(parent::getClean()) : parent::getClean(); return Format::sanitize($clean); } function getSearchable() { // Replace tag chars with spaces (to ensure words are separated) $body = Format::html($this->body, array('hook_tag' => function($el, $attributes=0) { static $non_ws = array('wbr' => 1); return (isset($non_ws[$el])) ? '' : ' '; })); // Collapse multiple white-spaces $body = html_entity_decode($body, ENT_QUOTES); $body = preg_replace('`\s+`u', ' ', $body); return Format::searchable($body); } function prepend($what) { $this->body = sprintf('
%s

%s', $what, $this->body); } function display($output=false) { if ($this->isEmpty()) return '(empty)'; switch ($output) { case 'email': return $this->body; case 'pdf': return Format::clickableurls(Format::stripExternalImages($this->body, true)); default: return Format::display($this->body, true, !$this->options['balanced']); } } } /* Message - Ticket thread entry of type message */ class MessageThreadEntry extends ThreadEntry { const ENTRY_TYPE = 'M'; function getSubject() { return $this->getTitle(); } static function add($vars, &$errors=array()) { if (!$vars || !is_array($vars) || !$vars['threadId']) $errors['err'] = __('Missing or invalid data'); elseif (!$vars['message']) $errors['message'] = __('Message content is required'); if ($errors) return false; $vars['type'] = self::ENTRY_TYPE; $vars['body'] = $vars['message']; if (!$vars['poster'] && $vars['userId'] && ($user = User::lookup($vars['userId']))) $vars['poster'] = (string) $user->getName(); return parent::add($vars); } static function getVarScope() { $base = parent::getVarScope(); unset($base['staff']); return $base; } } /* thread entry of type response */ class ResponseThreadEntry extends ThreadEntry { const ENTRY_TYPE = 'R'; function getActivity() { return new ThreadActivity( _S('New Response'), _S('New response posted')); } function getSubject() { return $this->getTitle(); } function getRespondent() { return $this->getStaff(); } static function add($vars, &$errors=array()) { if (!$vars || !is_array($vars) || !$vars['threadId']) $errors['err'] = __('Missing or invalid data'); elseif (!$vars['response']) $errors['response'] = __('Response content is required'); if ($errors) return false; $vars['type'] = self::ENTRY_TYPE; $vars['body'] = $vars['response']; if (!$vars['pid'] && $vars['msgId']) $vars['pid'] = $vars['msgId']; if (!$vars['poster'] && $vars['staffId'] && ($staff = Staff::lookup($vars['staffId']))) $vars['poster'] = (string) $staff->getName(); return parent::add($vars); } static function getVarScope() { $base = parent::getVarScope(); unset($base['user']); return $base; } } /* Thread entry of type note (Internal Note) */ class NoteThreadEntry extends ThreadEntry { const ENTRY_TYPE = 'N'; function getMessage() { return $this->getBody(); } function getActivity() { return new ThreadActivity( _S('New Internal Note'), _S('New internal note posted')); } static function add($vars, &$errors=array()) { //Check required params. if (!$vars || !is_array($vars) || !$vars['threadId']) $errors['err'] = __('Missing or invalid data'); elseif (!$vars['note']) $errors['note'] = __('Note content is required'); if ($errors) return false; //TODO: use array_intersect_key when we move to php 5 to extract just what we need. $vars['type'] = self::ENTRY_TYPE; $vars['body'] = $vars['note']; return parent::add($vars); } static function getVarScope() { $base = parent::getVarScope(); unset($base['user']); return $base; } } // Object specific thread utils. class ObjectThread extends Thread implements TemplateVariable { static $types = array( ObjectModel::OBJECT_TYPE_TASK => 'TaskThread', ObjectModel::OBJECT_TYPE_TICKET => 'TicketThread', ); var $counts; function getCounts() { if (!isset($this->counts) && $this->getId()) { $this->counts = array(); $stuff = $this->entries ->values_flat('type') ->annotate(array( 'count' => SqlAggregate::COUNT('id') )); foreach ($stuff as $row) { list($type, $count) = $row; $this->counts[$type] = $count; } } return $this->counts; } function getNumMessages() { $this->getCounts(); return $this->counts[MessageThreadEntry::ENTRY_TYPE]; } function getNumResponses() { $this->getCounts(); return $this->counts[ResponseThreadEntry::ENTRY_TYPE]; } function getNumNotes() { $this->getCounts(); return $this->counts[NoteThreadEntry::ENTRY_TYPE]; } function getLastMessage($criteria=false) { $entries = clone $this->getEntries(); $entries->filter(array( 'type' => MessageThreadEntry::ENTRY_TYPE )); if ($criteria) $entries->filter($criteria); $entries->order_by('-id'); return $entries->first(); } function getLastEmailMessage($criteria=array()) { $criteria += array( 'source' => 'Email', 'email_info__headers__isnull' => false); return $this->getLastMessage($criteria); } function getLastEmailMessageByUser($user) { $uid = is_numeric($user) ? $user : 0; if (!$uid && ($user instanceof EmailContact)) $uid = $user->getUserId(); return $uid ? $this->getLastEmailMessage(array('user_id' => $uid)) : null; } function getEntry($criteria) { // XXX: PUNT if (is_numeric($criteria)) return parent::getEntry($criteria); $entries = clone $this->getEntries(); $entries->filter($criteria); return $entries->first(); } function getMessages() { $entries = clone $this->getEntries(); return $entries->filter(array( 'type' => MessageThreadEntry::ENTRY_TYPE )); } function getResponses() { $entries = clone $this->getEntries(); return $entries->filter(array( 'type' => ResponseThreadEntry::ENTRY_TYPE )); } function getNotes() { $entries = clone $this->getEntries(); return $entries->filter(array( 'type' => NoteThreadEntry::ENTRY_TYPE )); } function addNote($vars, &$errors=array()) { //Add ticket Id. $vars['threadId'] = $this->getId(); return NoteThreadEntry::add($vars, $errors); } function addMessage($vars, &$errors) { $vars['threadId'] = $this->getId(); $vars['staffId'] = 0; if (!($message = MessageThreadEntry::add($vars, $errors))) return $message; $this->lastmessage = SqlFunction::NOW(); $this->save(true); return $message; } function addResponse($vars, &$errors) { $vars['threadId'] = $this->getId(); $vars['userId'] = 0; if ($message = $this->getLastMessage()) $vars['pid'] = $message->getId(); $vars['flags'] = 0; if (!($resp = ResponseThreadEntry::add($vars, $errors))) return $resp; $this->lastresponse = SqlFunction::NOW(); $this->save(true); return $resp; } function __toString() { return $this->asVar(); } function asVar() { return new ThreadEntries($this); } function getVar($name) { switch ($name) { case 'original': $entry = $this->entries->filter(array( 'type' => MessageThreadEntry::ENTRY_TYPE, 'flags__hasbit' => ThreadEntry::FLAG_ORIGINAL_MESSAGE, )) ->order_by('id') ->first(); if ($entry) return $entry->getBody(); break; case 'last_message': case 'lastmessage': $entry = $this->getLastMessage(); if ($entry) return $entry->getBody(); break; case 'complete': return $this->asVar(); break; } } static function getVarScope() { return array( 'complete' =>array('class' => 'ThreadEntries', 'desc' => __('Thread Correspondence')), 'original' => array('class' => 'MessageThreadEntry', 'desc' => __('Original Message')), 'lastmessage' => array('class' => 'MessageThreadEntry', 'desc' => __('Last Message')), ); } static function lookup($criteria, $type=false) { if (!$type) return parent::lookup($criteria); $class = false; if (isset(self::$types[$type])) $class = self::$types[$type]; if (!class_exists($class)) $class = get_called_class(); return $class::lookup($criteria); } } class ThreadEntries { var $thread; function __construct($thread) { $this->thread = $thread; } function __tostring() { return (string) $this->getVar(); } function asVar() { return $this->getVar(); } function getVar($name='') { $order = ''; switch ($name) { case 'reversed': $order = '-'; default: $content = ''; $thread = $this->thread; ob_start(); include INCLUDE_DIR.'client/templates/thread-export.tmpl.php'; $content = ob_get_contents(); ob_end_clean(); return $content; break; } } static function getVarScope() { return array( 'reversed' => sprintf('%s %s', __('Thread Correspondence'), __('in reversed order')), ); } } // Ticket thread class class TicketThread extends ObjectThread { static function create($ticket=false) { assert($ticket !== false); $id = is_object($ticket) ? $ticket->getId() : $ticket; $thread = parent::create(array( 'object_id' => $id, 'object_type' => ObjectModel::OBJECT_TYPE_TICKET )); if ($thread->save()) return $thread; } } /** * Class: ThreadEntryAction * * Defines a simple action to be performed on a thread entry item, such as * viewing the raw email headers used to generate the message, resend the * confirmation emails, etc. */ abstract class ThreadEntryAction { static $name; // Friendly, translatable name static $id; // Unique identifier used for plumbing static $icon = 'cog'; var $entry; function getName() { $class = get_class($this); return __($class::$name); } static function getId() { return static::$id; } function getIcon() { $class = get_class($this); return 'icon-' . $class::$icon; } function getObJectId() { return $this->entry->getThread()->getObjectId(); } function __construct(ThreadEntry $thread) { $this->entry = $thread; } abstract function trigger(); function isEnabled() { return $this->isVisible(); } function isVisible() { return true; } /** * getJsStub * * Retrieves a small JavaScript snippet to insert into the rendered page * which should, via an AJAX callback, trigger this action to be * performed. The URL for this sort of activity is already provided for * you via the ::getAjaxUrl() method in this class. */ abstract function getJsStub(); /** * getAjaxUrl * * Generate a URL to be used as an AJAX callback. The URL can be used to * trigger this thread entry action via the callback. * * Parameters: * $dialog - (bool) used in conjunction with `$.dialog()` javascript * function which assumes the `ajax.php/` should be replace a leading * `#` in the url */ function getAjaxUrl($dialog=false) { return sprintf('%s%s/%d/thread/%d/%s', $dialog ? '#' : 'ajax.php/', $this->entry->getThread()->getObjectType() == 'T' ? 'tickets' : 'tasks', $this->entry->getThread()->getObjectId(), $this->entry->getId(), static::getId() ); } function getTicketsAPI() { return new TicketsAjaxAPI(); } function getTasksAPI() { return new TasksAjaxAPI(); } } interface Threadable { function getThreadId(); function getThread(); function postThreadEntry($type, $vars, $options=array()); function addCollaborator($user, $vars, &$errors, $event=true); } /** * ThreadActivity * * Object to thread activity * */ class ThreadActivity implements TemplateVariable { var $title; var $desc; function __construct($title, $desc) { $this->title = $title; $this->desc = $desc; } function getTitle() { return $this->title; } function getDescription() { return $this->desc; } function asVar() { return (string) $this->getTitle(); } function getVar($tag) { if ($tag && is_callable(array($this, 'get'.ucfirst($tag)))) return call_user_func(array($this, 'get'.ucfirst($tag))); return false; } static function getVarScope() { return array( 'title' => __('Activity Title'), 'description' => __('Activity Description'), ); } } ?>