Newer
Older
<?php
/*********************************************************************
class.thread.php
XXX: Please DO NOT add any ticket related logic! use ticket class.
Peter Rotich <peter@osticket.com>
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');
class Thread extends VerySimpleModel {
static $meta = array(
'table' => THREAD_TABLE,
'pk' => array('id'),
'joins' => array(
'ticket' => array(
'constraint' => array(
'object_type' => "'T'",
'object_id' => 'TicketModel.ticket_id',
'task' => array(
'constraint' => array(
'object_type' => "'A'",
'object_id' => 'Task.id',
),
),
'collaborators' => array(
'reverse' => 'Collaborator.thread',
),
'entries' => array(
'reverse' => 'ThreadEntry.thread',
'events' => array(
'reverse' => 'ThreadEvent.thread',
'broker' => 'ThreadEvents',
const MODE_STAFF = 1;
const MODE_CLIENT = 2;
var $_object;
var $_collaborators; // Cache for collabs
return $this->id;
return $this->object_id;
return $this->object_type;
if (!$this->_object)
$this->_object = ObjectModel::lookup(
$this->getObjectId(), $this->getObjectType());
return Attachment::objects()->filter(array(
'thread_entry__thread' => $this
))->count();
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;
// Collaborators
function getNumCollaborators() {
return $this->collaborators->count();
if (!isset($this->ht['active_collaborators']))
$this->ht['active_collaborators'] = count($this->getActiveCollaborators());
return $this->ht['active_collaborators'];
function getActiveCollaborators() {
return $this->getCollaborators(array('isactive'=>1));
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('isactive' => $criteria['isactive']));
// TODO: sort by name of the user
$collaborators->order_by('user__name');
if (!$criteria)
$this->_collaborators = $collaborators;
return $collaborators;
function addCollaborator($user, $vars, &$errors, $event=true) {
if (!$user)
return null;
$vars = array_merge(array(
'threadId' => $this->getId(),
'userId' => $user->getId()), $vars);
if (!($c=Collaborator::add($vars, $errors)))
return null;
$this->_collaborators = null;
if ($event)
$this->getEvents()->log($this->getObject(),
'collab',
array('add' => array($user->getId() => array(
'name' => $user->getName()->getOriginal(),
'src' => @$vars['source'],
))
)
);
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->getEvents()->log($this->getObject(), 'collab', array(
'del' => array($c->user_id => array('name' => $c->getName()->getOriginal()))
));
//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(),
'isactive' => 1,
));
}
$this->collaborators->filter(array(
'thread_id' => $this->getId(),
Q::not(array('id__in' => $cids ?: array(0)))
))->update(array(
'updated' => SqlFunction::NOW(),
'isactive' => 0,
));
unset($this->ht['active_collaborators']);
$this->_collaborators = null;
return true;
//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;
}
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))
$entries->filter(array('type__in' => $type));
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();
$inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
include $inc . 'templates/thread-entries.tmpl.php';
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<email>) 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?
}
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' => '',
'recipients' => $mailinfo['recipients'],
'to-email-id' => $mailinfo['to-email-id'],
);
// XXX: Is this necessary?
if ($object instanceof Ticket)
$vars['ticketId'] = $object->getId();
if ($object instanceof Task)
$vars['taskId'] = $object->getId();
if (isset($mailinfo['attachments']))
$vars['attachments'] = $mailinfo['attachments'];
// 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'] = 'N';
$vars['staffId'] = $mailinfo['staffId'];
if ($vars['staffId'])
$vars['poster'] = Staff::lookup($mailinfo['staffId']);
// 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();
}
// 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
//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';
}
switch ($vars['thread-type']) {
case 'M':
$vars['message'] = $body;
if ($object instanceof Threadable)
return $object->postThreadEntry('M', $vars);
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);
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;
$deleted = Attachment::objects()->filter(array(
'thread_entry__thread' => $this,
))->delete();
if ($deleted)
function removeCollaborators() {
return Collaborator::objects()
->filter(array('thread_id'=>$this->getId()))
->delete();
/**
* 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;
}
}
if (!parent::delete())
return false;
ThreadEntryEmailInfo::objects()
->filter(array('thread_entry__thread' => $this))
->update(array('headers' => null));
$this->removeCollaborators();
$this->entries->delete();
// Null out the events
$this->events->update(array('thread_id' => 0));
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 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,
),
'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_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 PERM_EDIT = 'thread.edit';
var $_headers;
var $_thread;
static protected $perms = array(
self::PERM_EDIT => array(
'title' => /* @trans */ 'Edit Thread',
'desc' => /* @trans */ 'Ability to edit thread items of other agents',
),
);
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() {
function getType() {
return $this->type;
}
function getSource() {
return $this->source;
}
function getPoster() {
return $this->poster;
}
function getTitle() {
return $this->title;
}
function getBody() {
return ThreadEntryBody::fromFormattedText($this->body, $this->format,
array('balanced' => $this->hasFlag(self::FLAG_BALANCED))
);
function setBody($body) {
global $cfg;
if ($cfg->isRichTextEnabled())
$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;
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
/**
* Retrieve a list of all the recients of this message if the message
* was received via email.
*
* Returns:
* (array<RFC_822>) 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:
* <ThreadEntry> 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'];
}
return $this->thread_id;
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;
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 $name => $file) {
$F = array('inline' => is_array($file) && @$file['inline']);
if (is_numeric($file))
$fileId = $file;
elseif ($file instanceof AttachmentFile)
$fileId = $file->getId();
elseif (is_array($file) && isset($file['id']))
$fileId = $file['id'];
elseif ($AF = AttachmentFile::create($file))
$fileId = $AF->getId();
elseif ($add_error) {
$error = $file['error']
?: sprintf(_S('Unable to import attachment - %s'),
$name ?: $file['name']);
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,
));
}
$F['id'] = $fileId;
if (is_string($name))
$F['name'] = $name;
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($file))
$F += $file;
// Key is required for CID rewriting in the body
if (!isset($F['key']) && ($AF = AttachmentFile::lookup($F['id'])))
$F['key'] = $AF->key;
}
/*
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
if ($F && 0 !== strcasecmp($F->name, $filename))
$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;
}
foreach ($this->attachments as $att) {
$json[$att->file->getKey()] = array(