Newer
Older
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());
}
return false;
}
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 ($entry = ThreadEntry::objects()
->filter(array('email_info__mid' => $mailinfo['mid']))
->first()
) {
return $entry;
foreach (array('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[0]));
}
// Add the message id if it is embedded in the body
$match = array();
if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`',
$mailinfo['message'], $match)
&& !in_array($match[1], $possibles)
) {
$possibles[] = $match[1];
}
$thread = null;
foreach ($possibles as $mid) {
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
// 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['loopback'] && 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'];
}
elseif (@$mid_info['staffId']) {
$mailinfo['staffId'] = $mid_info['staffId'];
}
// ThreadEntry was positively identified
return $t;
}
// Try to determine if it's a reply to a tagged email.
// (Deprecated)
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
$ref = null;
if (strpos($mid, '+')) {
list($left, $right) = explode('@',$mid);
list($left, $ref) = explode('+', $left);
$mid = "$left@$right";
}
$entries = ThreadEntry::objects()
->filter(array('email_info__mid' => $mid));
foreach ($entries as $t) {
// Capture the first match thread item
if (!$thread)
$thread = $t;
// We found a match - see if we can ID the user.
// XXX: Check access of ref is enough?
if ($ref && ($uid = $t->getUIDFromEmailReference($ref))) {
if ($ref[0] =='s') //staff
$mailinfo['staffId'] = $uid;
else // user or collaborator.
$mailinfo['userId'] = $uid;
// Best possible case — found the thread and the
// user
return $t;
// Second best case — found a thread but couldn't identify the
// user from the header. Return the first thread entry matched
if ($thread)
return $thread;
// 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();
}
}
// Search for the message-id token in the body
// *DEPRECATED* the current algo on outgoing mail will use
// Mailer::getMessageId as the message id tagged here
if (preg_match('`(?:class="mid-|Ref-Mid: )([^"\s]*)(?:$|")`',
$mailinfo['message'], $match)) {
// Support new Message-Id format
if (($info = Mailer::decodeMessageId($match[1]))
&& $info['loopback']
&& $info['entryId']
) {
return ThreadEntry::lookup($info['entryId']);
}
// Support old (deprecated) reference format
if ($thread = ThreadEntry::lookupByRefMessageId($match[1],
$mailinfo['email']))
return $thread;
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)
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)
//new entry ... we're trusting the caller to check validity of the data.
static function create($vars, &$errors=array()) {
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);
static function add($vars, &$errors=array()) {
return self::create($vars);
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
// 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;
}
RolePermission::register(/* @trans */ 'Tickets', ThreadEntry::getPermissions());
class ThreadEvent extends VerySimpleModel {
static $meta = array(
'table' => TICKET_EVENT_TABLE,
'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'),
),
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
1480
1481
1482
'user' => array(
'constraint' => array(
'uid' => 'User.id',
),
'null' => true,
),
'dept' => array(
'constraint' => array(
'dept_id' => 'Dept.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 TRANFERRED = 'transferred';
const VIEWED = 'viewed';
const MODE_STAFF = 1;
const MODE_CLIENT = 2;
var $_data;
function getAvatar($size=16) {
if ($this->uid && $this->uid_type == 'S')
return $this->agent->get_gravatar($size);
if ($this->uid && $this->uid_type == 'U')
return $this->user->get_gravatar($size);
}
function getUserName() {
if ($this->uid && $this->uid_type == 'S')
return $this->agent->getName();
if ($this->uid && $this->uid_type == 'U')
return $this->user->getName();
return $this->username;
}
function getIcon() {
$icons = array(
'assigned' => 'hand-right',
'collab' => 'group',
'created' => 'magic',
'overdue' => 'time',
'transferred' => 'share-alt',
'edited' => 'pencil',
);
return @$icons[$this->state] ?: 'chevron-sign-right';
}
function getDescription() {
static $descs;
if (!isset($descs))
$descs = array(
'assigned' => __('Assignee changed by <b>{username}</b> to <strong>{assignees}</strong> {timestamp}'),
'assigned:staff' => __('<b>{username}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}'),
'assigned:team' => __('<b>{username}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}'),
'assigned:claim' => __('<b>{username}</b> claimed this {timestamp}'),
'collab:org' => __('Collaborators for {<Organization>data.org} organization added'),
'collab:del' => function($evt) {
$data = $evt->getData();
$base = __('<b>{username}</b> removed %s from the collaborators.');
return $data['del']
? Format::htmlchars(sprintf($base, implode(', ', $data['del'])))
: 'somebody';
},
'collab:add' => function($evt) {
$data = $evt->getData();
$base = __('<b>{username}</b> added <strong>%s</strong> as collaborators {timestamp}');
$collabs = array();
if ($data['add']) {
foreach ($data['add'] as $c) {
$collabs[] = Format::htmlchars($c);
}
}
return $collabs
? sprintf($base, implode(', ', $collabs))
: 'somebody';
},
'created' => __('Created by <b>{username}</b> {timestamp}'),
'closed' => __('Closed by <b>{username}</b> {timestamp}'),
'reopened' => __('Reopened by <b>{username}</b> {timestamp}'),
'edited:owner' => __('<b>{username}</b> changed ownership to {<User>data.owner} {timestamp}'),
'edited:status' => __('<b>{username}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}'),
'overdue' => __('Flagged as overdue by the system {timestamp}'),
'transferred' => __('<b>{username}</b> transferred this to <strong>{dept}</strong> {timestamp}'),
'edited:fields' => function($evt) {
$base = __('Updated by <b>{username}</b> {timestamp} — %s');
$data = $evt->getData();
$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) {
$field = $fields[$id];
list($old, $new) = $f;
$impl = $field->getImpl($field);
$before = $impl->to_php($old);
$after = $impl->to_php($new);
$changes[] = sprintf('<strong>%s</strong> %s',
$field->getLocal('label'), $impl->whatChanged($before, $after));
}
return sprintf($base, implode(', ', $changes));
},
);
$self = $this;
$data = $this->getData();
$state = $this->state;
if (is_array($data)) {
foreach (array_keys($data) as $k)
if (isset($descs[$state . ':' . $k]))
$state .= ':' . $k;
}
$description = $descs[$state];
if (is_callable($description))
$description = $description($this);
return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/',
function ($m) use ($self) {
switch ($m['key']) {
case 'assignees':
$assignees = array();
if ($S = $this->staff) {
$url = $S->get_gravatar(16);
$assignees[] =
"<img class=\"avatar\" src=\"{$url}\"> ".$S->getName();
}
if ($T = $this->team) {
$assignees[] = $T->getLocalName();
}
return implode('/', $assignees);
$name = $self->getUserName();
if ($url = $self->getAvatar())
$name = "<img class=\"avatar\" src=\"{$url}\"> ".$name;
return $name;
case 'timestamp':
return sprintf('<time class="relative" datetime="%s" title="%s">%s</time>',
date(DateTime::W3C, Misc::db2gmtime($self->timestamp)),
Format::daydatetime($self->timestamp),
Format::relativeTime(Misc::db2gmtime($self->timestamp))
);
case 'agent':
$st = $this->agent;
if ($url = $self->getAvatar())
$name = "<img class=\"avatar\" src=\"{$url}\"> ".$name;
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
case 'dept':
if ($dept = $this->getDept())
return $dept->getLocalName();
case 'data':
$val = $self->getData($m['data']);
if ($m['type'] && class_exists($m['type']))
$val = $m['type']::lookup($val);
return (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;
include $inc . 'templates/thread-event.tmpl.php';
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
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
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
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
}
static function create($ht=false) {
$inst = parent::create($ht);
$inst->timestamp = SqlFunction::NOW();
global $thisstaff, $thisclient;
if ($thisstaff) {
$inst->uid_type = 'S';
$inst->uid = $thisstaff->getId();
}
else if ($thisclient) {
$inst->uid_type = 'U';
$inst->uid = $thisclient->getId();
}
return $inst;
}
static function forTicket($ticket, $state) {
$inst = static::create(array(
'staff_id' => $ticket->getStaffId(),
'team_id' => $ticket->getTeamId(),
'dept_id' => $ticket->getDeptId(),
'topic_id' => $ticket->getTopicId(),
));
if (!isset($inst->uid_type) && $state == self::CREATED) {
$inst->uid_type = 'U';
$inst->uid = $ticket->getOwnerId();
}
return $inst;
}
}
class ThreadEvents extends InstrumentedList {
function annul($event) {
$this->queryset
->filter(array('state' => $event))
->update(array('annulled' => 1));
}
function log($object, $state, $data=null, $annul=null, $username=null) {
if ($object instanceof Ticket)
$event = ThreadEvent::forTicket($object, $state);
else
$event = ThreadEvent::create();
# 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);
}
if ($username === null) {
if ($thisstaff) {
$username = $thisstaff->getUserName();
}
else if ($thisclient) {
if ($thisclient->hasAccount)
$username = $thisclient->getAccount()->getUserName();
if (!$username)
$username = $thisclient->getEmail();
}
else {
# XXX: Security Violation ?
$username = 'SYSTEM';
}
}
$event->username = $username;
$event->state = $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();
}
}
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=array()) {
return static::add($vars, $errors);
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 getSubject() {
return $this->getTitle();
}
function getRespondent() {
return $this->getStaff();
}
static function create($vars, &$errors=array()) {
return static::add($vars, $errors);
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;