Newer
Older
// 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)
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($to . $ticket->getNumber() . $ticket->getId()), -10),
substr($domain, -10)
);
if ($check != $mails)
//new entry ... we're trusting the caller to check validity of the data.
if ($cfg->isHtmlThreadEnabled())
$vars['body'] = new HtmlThreadEntryBody($vars['body']);
else
$vars['body'] = new TextThreadEntryBody($vars['body']);
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;
}
}
if (!($body = $vars['body']->getClean()))
$body = '-'; //Special tag used to signify empty message as stored.
$poster = $vars['poster'];
if ($poster && is_object($poster))
$entry = parent::create(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'],
));
if (!isset($vars['attachments']) || !$vars['attachments'])
// Otherwise, body will be configured in a block below (after
// inline attachments are saved and updated in the database)
$entry->body = $body;
$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'];
if (!$entry->save())
return false;
/************* ATTACHMENTS *****************/
//Upload/save attachments IF ANY
if($vars['files']) //expects well formatted and VALIDATED files array.
$entry->uploadFiles($vars['files']);
//Canned attachments...
if($vars['cannedattachments'] && is_array($vars['cannedattachments']))
$entry->saveAttachments($vars['cannedattachments']);
//Emailed or API attachments
if (isset($vars['attachments']) && $vars['attachments']) {
foreach ($vars['attachments'] as &$a)
if (isset($a['cid']) && $a['cid']
&& strpos($body, 'cid:'.$a['cid']) !== false)
$a['inline'] = true;
unset($a);
$entry->importAttachments($vars['attachments']);
// 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
// will be available to retrieve the image later
if ($a['cid'] && $a['key']) {
$body = preg_replace('/src=("|\'|\b)(?:cid:)?'
. preg_quote($a['cid'], '/').'\1/i',
'src="cid:'.$a['key'].'"', $body);
$entry->body = $body;
if (!$entry->save())
// Save mail message id, if available
$entry->saveEmailInfo($vars);
// Inline images (attached to the draft)
$entry->saveAttachments(Draft::getAttachmentIds($body));
Signal::send('threadentry.created', $entry);
return self::create($vars);
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
// 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 $types = array('text', 'html');
var $body;
var $type;
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");
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) . ' ... '
$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('<pre>%s</pre>',
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,
// Strip the quoted part of the body
if ((list($msg) = explode($tag, $this->body, 2)) && trim($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() {
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');
static function fromFormattedText($text, $format=false) {
switch ($format) {
case 'text':
return new HtmlThreadEntryBody($text, array('strip-embedded'=>false));
class TextThreadEntryBody extends ThreadEntryBody {
function __construct($body, $options=array()) {
parent::__construct($body, 'text', $options);
}
function getClean() {
return Format::stripEmptyLines($this->body);
}
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 '<div style="white-space:pre-wrap">'
.Format::clickableurls($escaped).'</div>';
return '<div style="white-space:pre-wrap">'
return nl2br($escaped);
return '<pre>'.$escaped.'</pre>';
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() {
return trim($this->body, " <>br/\t\n\r") ? Format::sanitize($this->body) : '';
function getSearchable() {
// <br> -> \n
$body = preg_replace(array('`<br(\s*)?/?>`i', '`</div>`i'), "\n", $this->body); # <?php
$body = Format::htmldecode(Format::striptags($body));
return Format::searchable($body);
function prepend($what) {
$this->body = sprintf('<div>%s<br/><br/></div>%s', $what, $this->body);
}
function display($output=false) {
if ($this->isEmpty())
return '(empty)';
switch ($output) {
case 'email':
return $this->body;
default:
return Format::display($this->body);
}
/* Message - Ticket thread entry of type message */
class MessageThreadEntry extends ThreadEntry {
const ENTRY_TYPE = 'M';
function getSubject() {
return $this->getTitle();
}
static function create($vars, &$errors) {
return static::add($vars, $errors);
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
}
static function add($vars, &$errors) {
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);
}
}
/* thread entry of type response */
class ResponseThreadEntry extends ThreadEntry {
const ENTRY_TYPE = 'R';
function getSubject() {
return $this->getTitle();
}
function getRespondent() {
return $this->getStaff();
}
static function create($vars, &$errors) {
return static::add($vars, $errors);
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
}
static function add($vars, &$errors) {
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);
}
}
/* Thread entry of type note (Internal Note) */
class NoteThreadEntry extends ThreadEntry {
const ENTRY_TYPE = 'N';
function getMessage() {
return $this->getBody();
}
static function create($vars, &$errors) {
return self::add($vars, $errors);
}
static function add($vars, &$errors) {
//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);
}
}
// Object specific thread utils.
class ObjectThread extends Thread {
static $types = array(
ObjectModel::OBJECT_TYPE_TASK => 'TaskThread',
);
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;
$this->getCounts();
return $this->counts[MessageThreadEntry::ENTRY_TYPE];
$this->getCounts();
return $this->counts[ResponseThreadEntry::ENTRY_TYPE];
$this->getCounts();
return $this->counts[NoteThreadEntry::ENTRY_TYPE];
return $this->entries->filter(array(
'type' => MessageThreadEntry::ENTRY_TYPE
));
}
function getLastMessage() {
return $this->entries->filter(array(
'type' => MessageThreadEntry::ENTRY_TYPE
))
->order_by('-id')
->first();
}
function getEntry($var) {
// XXX: PUNT
if (is_numeric($var))
$id = $var;
else {
$criteria = array_merge($var, array('limit' => 1));
$entries = $this->getEntries($criteria);
if ($entries && $entries[0])
$id = $entries[0]['id'];
}
return $id ? parent::getEntry($id) : null;
return $this->entries->filter(array(
'type' => ResponseThreadEntry::ENTRY_TYPE
));
return $this->entries->filter(array(
'type' => NoteThreadEntry::ENTRY_TYPE
));
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
}
function addNote($vars, &$errors) {
//Add ticket Id.
$vars['threadId'] = $this->getId();
return NoteThreadEntry::create($vars, $errors);
}
function addMessage($vars, &$errors) {
$vars['threadId'] = $this->getId();
$vars['staffId'] = 0;
return MessageThreadEntry::create($vars, $errors);
}
function addResponse($vars, &$errors) {
$vars['threadId'] = $this->getId();
$vars['userId'] = 0;
return ResponseThreadEntry::create($vars, $errors);
}
function getVar($name) {
switch ($name) {
$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();
static function lookup($criteria, $type=false) {
if (!$type)
return parent::lookup($criteria);
if (isset(self::$types[$type]))
$class = self::$types[$type];
if (!class_exists($class))
$class = get_called_class();
return $class::lookup($criteria);
}
}
// Ticket thread class
class TicketThread extends ObjectThread {
static function create($ticket) {
$id = is_object($ticket) ? $ticket->getId() : $ticket;
$thread = parent::create(array(
'object_type' => ObjectModel::OBJECT_TYPE_TICKET
));
if ($thread->save())
return $thread;
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
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
/**
* 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 $thread;
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 __construct(ThreadEntry $thread) {
$this->thread = $thread;
}
abstract function trigger();
function getTicket() {
return $this->thread->getTicket();
}
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('%stickets/%d/thread/%d/%s',
$dialog ? '#' : 'ajax.php/',
$this->thread->getThread()->getObjectId(),
$this->thread->getId(),
static::getId()
);
}
}
function postThreadEntry($type, $vars);