Newer
Older
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.',
);
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
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(
'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
foreach ($this->attachments as $att ) {
if ($att->inline) continue;
if ($att->file->size)
$size=sprintf('<em>(%s)</em>', Format::file_size($att->file->size));
$str .= sprintf(
'<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%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'])
$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) {
$this->email_info = new ThreadEntryEmailInfo(array(
'thread_entry_id' => $id,
'mid' => $mid,
));
$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
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'),
'class' => 'Staff', 'desc' => __('Agent posting the note or response'),
'title' => __('Subject, if any'),
'class' => 'User', 'desc' => __('User posting the message'),
/**
* Parameters:
* mailinfo (hash<String>) 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()
)
) {
if ($mailinfo['system_emails']
&& ($t = $entry->getThread()->getObject())
&& $t instanceof Ticket)
$t->systemReferral($mailinfo['system_emails']);
return $entry;
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
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;
}
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) {
$mid = trim($mid, '<>');
list($ver, $ids, $mails) = explode('$', $mid, 3);
// Current version is <null>
if ($ver !== '')
return false;
$ids = @unpack('Vthread', base64_decode($ids));
if (!$ids || !$ids['thread'])
return false;
$entry = ThreadEntry::lookup($ids['thread']);
if (!$entry)
// 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)
//new entry ... we're trusting the caller to check validity of the data.
static function create($vars=false) {
if ($cfg->isRichTextEnabled())
$vars['body'] = new HtmlThreadEntryBody($vars['body']);
else
$vars['body'] = new TextThreadEntryBody($vars['body']);
if (!($body = $vars['body']->getClean()))
$body = '-'; //Special tag used to signify empty message as stored.
$poster = $vars['poster'];
if ($poster && is_object($poster))
$entry = new static(array(
'created' => SqlFunction::NOW(),
'type' => $vars['type'],
'thread_id' => $vars['threadId'],
'title' => 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;
$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();
$entry->ip_address = $vars['ip_address'];
/************* ATTACHMENTS *****************/
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
// 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 (<img src="data:base64,..." />).
// 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'], $vars['cannedattachments'],
// 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 <img src="cid:"> 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)
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
// 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);
static function add($vars, &$errors=array()) {
return self::create($vars);
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
// 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());
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
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('<i class="icon-%s"></i> %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(
'pk' => array('id'),
'joins' => array(
// Originator of activity
'agent' => array(
'constraint' => array(
'uid' => 'Staff.staff_id',
),
'null' => true,
'staff' => array(
'constraint' => array(
'staff_id' => 'Staff.staff_id',
),
'null' => true,
),
'team' => array(
'constraint' => array(
'team_id' => 'Team.team_id',
'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,
),
// Valid events for database storage
const ASSIGNED = 'assigned';
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 VIEWED = 'viewed';
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;
return $this->user ? $this->user->getName() : $this->username;
return $this->username;
}
function getIcon() {
$icons = array(
'collab' => 'group',
'created' => 'magic',
'overdue' => 'time',
'transferred' => 'share-alt',
'closed' => 'thumbs-up-alt',
'reopened' => 'rotate-right',
'resent' => 'reply-all icon-flip-horizontal',
);
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
function template($description, $mode=self::MODE_STAFF) {
return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/',
function ($m) use ($self, $thisstaff, $cfg, $hideName, $mode) {
case 'assignees':
$assignees = array();
$avatar = '';
if ($cfg->isAvatarsEnabled())
$avatar = $S->getAvatar();
$avatar.$S->getName();
$assignees[] = $T->getLocalName();
}
return implode('/', $assignees);
if ($hideName && $self->agent && $mode == self::MODE_CLIENT)
$name = __('Staff');
else
$name = $self->getUserName();
if ($cfg->isAvatarsEnabled()
&& ($avatar = $self->getAvatar()))
$name = $avatar.$name;
$timeFormat = null;
if ($thisstaff
&& !strcasecmp($thisstaff->datetime_format,
'relative')) {
$timeFormat = function ($timestamp) {
return Format::relativeTime(Misc::db2gmtime($timestamp));
};
}
return sprintf('<time %s datetime="%s"
data-toggle="tooltip" title="%s">%s</time>',
$timeFormat ? 'class="relative"' : '',
date(DateTime::W3C, Misc::db2gmtime($self->timestamp)),
Format::daydatetime($self->timestamp),
$timeFormat ? $timeFormat($self->timestamp) :
Format::datetime($self->timestamp)
if ($cfg->isAvatarsEnabled()
&& ($avatar = $self->getAvatar()))
$name = $avatar.$name;
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;
$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);
$user = is_object($user) ? $user : $thisstaff ?: $thisclient;
if ($user instanceof Staff) {