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');
class ThreadModel extends VerySimpleModel {
static $meta = array(
'table' => THREAD_TABLE,
'pk' => array('id'),
'joins' => array(
'ticket' => array(
'constraint' => array(
'object_id' => 'TicketModel.ticket_id',
'object_type' => "'T'",
),
),
'entries' => array(
'reverse' => 'ThreadEntryModel.thread',
),
),
);
}
function Thread($criteria) {
$this->load($criteria);
if (!$criteria && !($criteria=$this->getId()))
.' ,count(DISTINCT entry.id) as entries '
.' FROM '.THREAD_TABLE.' thread '
.' LEFT JOIN '.THREAD_ENTRY_TABLE.' entry
ON (entry.thread_id = thread.id) '
ON (a.object_id=entry.id AND a.`type` = "H") ';
if (is_numeric($criteria))
$sql.= ' WHERE thread.id='.db_input($criteria);
else
$sql.= sprintf(' WHERE thread.object_id=%d AND
thread.object_type=%s',
$criteria['object_id'],
db_input($criteria['object_type']));
$sql.= ' GROUP BY thread.id';
$this->ht = array();
if (($res=db_query($sql)) && db_num_rows($res))
$this->ht = db_fetch_array($res);
function getObjectId() {
return $this->ht['object_id'];
function getObjectType() {
return $this->ht['object_type'];
function getObject() {
if (!$this->_object)
$this->_object = ObjectModel::lookup(
$this->getObjectId(), $this->getObjectType());
return $this->_object;
}
function getNumAttachments() {
return $this->ht['attachments'];
function getNumEntries() {
return $this->ht['entries'];
function getEntries($criteria) {
if (!$criteria['order'] || !in_array($criteria['order'], array('DESC','ASC')))
$criteria['order'] = 'ASC';
Peter Rotich
committed
, COALESCE(user.name,
IF(staff.staff_id,
CONCAT_WS(" ", staff.firstname, staff.lastname),
NULL)) as name '
.' ,count(DISTINCT attach.id) as attachments '
.' FROM '.THREAD_ENTRY_TABLE.' entry '
Peter Rotich
committed
.' LEFT JOIN '.USER_TABLE.' user
Peter Rotich
committed
.' LEFT JOIN '.STAFF_TABLE.' staff
.' LEFT JOIN '.ATTACHMENT_TABLE.' attach
ON (attach.object_id = entry.id AND attach.`type`="H") '
.' WHERE entry.thread_id='.db_input($this->getId());
if ($criteria['type'] && is_array($criteria['type']))
$sql.=' AND entry.`type` IN ('
.implode(',', db_input($criteria['type'])).')';
elseif ($criteria['type'])
$sql.=' AND entry.`type` = '.db_input($criteria['type']);
.' ORDER BY entry.created '.$criteria['order'];
if ($criteria['limit'])
$sql.=' LIMIT '.$criteria['limit'];
if(($res=db_query($sql)) && db_num_rows($res)) {
while($rec=db_fetch_array($res)) {
$rec['body'] = ThreadEntryBody::fromFormattedText($rec['body'], $rec['format']);
return $entries;
}
function getEntry($id) {
}
function deleteAttachments() {
// Clear reference table
$sql = 'DELETE `a`.* FROM '.ATTACHMENT_TABLE. ' `a` '
. 'INNER JOIN '.THREAD_ENTRY_TABLE.' `e`
ON(`e`.id = `a`.object_id AND `a`.`type`= "H") '
. ' WHERE `e`.thread_id='.db_input($this->getId());
$deleted=0;
if (($res=db_query($sql)) && ($deleted=db_affected_rows()))
AttachmentFile::deleteOrphans();
return $deleted;
}
function delete() {
//Self delete
$sql = 'DELETE FROM '.THREAD_TABLE.' WHERE
id='.db_input($this->getId());
// Clear email meta data (header..etc)
$sql = 'UPDATE '.THREAD_ENTRY_EMAIL_TABLE.' email '
. 'INNER JOIN '.THREAD_ENTRY_TABLE.' entry
ON (entry.id = email.thread_entry_id) '
. 'SET email.headers = null '
. 'WHERE entry.thread_id = '.db_input($this->getId());
db_query($sql);
// Mass delete entries
$sql = 'DELETE FROM '.THREAD_ENTRY_TABLE
. ' WHERE thread_id='.db_input($this->getId());
db_query($sql);
static function create($vars) {
if (!$vars || !$vars['object_id'] || !$vars['object_type'])
return false;
$sql = 'INSERT INTO '.THREAD_TABLE.' SET created=NOW() '
.', object_id='.db_input($vars['object_id'])
.', object_type='.db_input($vars['object_type']);
if (db_query($sql))
return static::lookup(db_insert_id());
return null;
}
static function lookup($id) {
return ($id
&& ($thread = new Thread($id))
&& $thread->getId()
)
? $thread : null;
}
class ThreadEntryModel extends VerySimpleModel {
static $meta = array(
'table' => THREAD_ENTRY_TABLE,
'pk' => array('id'),
'joins' => array(
'thread' => array(
'constraint' => array('thread_id' => 'ThreadModel.id'),
),
'attachments' => array(
'reverse' => 'AttachmentModel.thread',
'null' => true,
),
),
);
}
class ThreadEntry {
var $id;
var $ht;
function ThreadEntry($id, $threadId=0, $type='') {
$this->load($id, $threadId, $type);
return false;
$sql='SELECT entry.*, email.mid, email.headers '
.' ,count(DISTINCT attach.id) as attachments '
.' FROM '.THREAD_ENTRY_TABLE.' entry '
.' LEFT JOIN '.THREAD_ENTRY_EMAIL_TABLE.' email
ON (email.thread_entry_id=entry.id) '
.' LEFT JOIN '.ATTACHMENT_TABLE.' attach
ON (attach.object_id=entry.id AND attach.`type` = "H") '
if ($type)
$sql.=' AND entry.type='.db_input($type);
if ($threadId)
$sql.=' AND entry.thread_id='.db_input($threadId);
return false;
$this->ht = db_fetch_array($res);
$this->id = $this->ht['id'];
$this->attachments = new GenericAttachments($this->id, 'H');
return true;
}
function reload() {
return $this->load();
}
function getId() {
return $this->id;
}
function getPid() {
return $this->ht['pid'];
}
function getType() {
}
function getSource() {
return $this->ht['source'];
}
function getPoster() {
return $this->ht['poster'];
}
function getTitle() {
return $this->ht['title'];
}
function getBody() {
return ThreadEntryBody::fromFormattedText($this->ht['body'], $this->ht['format']);
function setBody($body) {
global $cfg;
if ($cfg->isHtmlThreadEnabled())
$sql='UPDATE '.THREAD_ENTRY_TABLE.' SET updated=NOW()'
.',format='.db_input($body->getType())
.',body='.db_input((string) $body)
.' WHERE id='.db_input($this->getId());
return db_query($sql) && db_affected_rows();
}
function getCreateDate() {
return $this->ht['created'];
}
function getUpdateDate() {
return $this->ht['updated'];
}
function getNumAttachments() {
return $this->ht['attachments'];
}
function getEmailMessageId() {
function getEmailHeaderArray() {
require_once(INCLUDE_DIR.'class.mailparse.php');
if (!isset($this->ht['@headers']))
$this->ht['@headers'] = Mail_Parse::splitHeaders($this->ht['headers']);
return $this->ht['@headers'];
function getEmailReferences($include_mid=true) {
$references = '';
$headers = self::getEmailHeaderArray();
if (isset($headers['References']) && $headers['References'])
$references = $headers['References']." ";
if ($include_mid)
$references .= $this->getEmailMessageId();
return $references;
function getTaggedEmailReferences($prefix, $refId) {
$ref = "+$prefix".Base32::encode(pack('VV', $this->getId(), $refId));
$mid = substr_replace($this->getEmailMessageId(),
$ref, strpos($this->getEmailMessageId(), '@'), 0);
return sprintf('%s %s', $this->getEmailReferences(false), $mid);
}
function getEmailReferencesForUser($user) {
return $this->getTaggedEmailReferences('u',
($user instanceof Collaborator)
? $user->getUserId()
: $user->getId());
}
function getEmailReferencesForStaff($staff) {
return $this->getTaggedEmailReferences('s', $staff->getId());
}
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->ht['thread_id'];
}
function getThread() {
if(!$this->thread && $this->getThreadId())
$this->thread = Thread::lookup($this->getThreadId());
}
function getStaffId() {
return $this->ht['staff_id'];
}
function getStaff() {
if(!$this->staff && $this->getStaffId())
$this->staff = Staff::lookup($this->getStaffId());
return $this->staff;
}
function getUserId() {
return $this->ht['user_id'];
}
function getUser() {
if (!isset($this->user))
$this->user = User::lookup($this->getUserId());
return $this->user;
}
function getEmailHeader() {
return $this->ht['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());
}
//Web uploads - caller is expected to format, validate and set any errors.
function uploadFiles($files) {
if(!$files || !is_array($files))
return false;
$uploaded=array();
foreach($files as $file) {
if($file['error'] && $file['error']==UPLOAD_ERR_NO_FILE)
continue;
if(!$file['error']
&& ($id=AttachmentFile::upload($file))
&& $this->saveAttachment($id))
$uploaded[]=$id;
else {
if(!$file['error'])
$error = sprintf(__('Unable to upload file - %s'),$file['name']);
elseif(is_numeric($file['error']))
$error ='Error #'.$file['error']; //TODO: Transplate to string.
else
$error = $file['error'];
/*
Log the error as an internal note.
XXX: We're doing it here because it will eventually become a thread post comment (hint: comments coming!)
XXX: logNote must watch for possible loops
*/
$this->getThread()->getObject()->logNote(__('File Upload Error'), $error, 'SYSTEM', false);
function importAttachments(&$attachments) {
if(!$attachments || !is_array($attachments))
return null;
$files = array();
foreach($attachments as &$attachment)
if(($id=$this->importAttachment($attachment)))
$files[] = $id;
return $files;
}
/* Emailed & API attachments handler */
function importAttachment(&$attachment) {
if(!$attachment || !is_array($attachment))
return null;
$id=0;
if ($attachment['error'] || !($id=$this->saveAttachment($attachment))) {
$error = sprintf(_S('Unable to import attachment - %s'),
$attachment['name']);
$this->getThread()->getObject()->logNote(
_S('File Import Error'), $error, _S('SYSTEM'), false);
}
return $id;
}
/*
Save attachment to the DB.
@file is a mixed var - can be ID or file hashtable.
*/
$inline = is_array($file) && @$file['inline'];
return $this->attachments->save($file, $inline);
}
function saveAttachments($files) {
$ids=array();
foreach ($files as $file)
if (($id=$this->saveAttachment($file)))
$ids[] = $id;
return $ids;
}
function getAttachments() {
function getAttachmentUrls($script='image.php') {
$json = array();
foreach ($this->getAttachments() as $att) {
$json[$att['key']] = array(
'download_url' => sprintf('attachment.php?id=%d&h=%s',
$att['attach_id'], $att['download']),
'filename' => $att['name'],
);
}
return $json;
}
function getAttachmentsLinks($file='attachment.php', $target='_blank', $separator=' ') {
foreach ($this->getAttachments() as $att ) {
if ($att['inline']) continue;
if ($att['size'])
$size=sprintf('<em>(%s)</em>', Format::file_size($att['size']));
$str.=sprintf('<a class="Icon file no-pjax" href="%s?id=%d&h=%s" target="%s">%s</a>%s %s',
$file,
$att['attach_id'],
$att['download'],
$target,
Format::htmlchars($att['name']),
$size,
$separator);
/**
* 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) {
// +==================+===================+=============+
// | 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 (!$ticket = $this->getTicket())
// Kind of hard to continue a discussion without a ticket ...
return false;
// Make sure the email is NOT already fetched... (undeleted emails)
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 message-id format of
// <code-random-mailbox@domain.tld>
// where code is a predictable string based on the SECRET_SALT of
// this osTicket installation. If this incoming mail matches the
// code, then it very likely originated from this system and looped
@list($code) = explode('-', $mailinfo['mid'], 2);
if (0 === strcasecmp(ltrim($code, '<'), substr(md5('mail'.SECRET_SALT), -9))) {
// 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);
$vars = array(
'mid' => $mailinfo['mid'],
'header' => $mailinfo['header'],
'ticketId' => $ticket->getId(),
'poster' => $mailinfo['name'],
'origin' => 'Email',
'source' => 'Email',
'ip' => '',
'reply_to' => $this,
'recipients' => $mailinfo['recipients'],
'to-email-id' => $mailinfo['to-email-id'],
if (isset($mailinfo['attachments']))
$vars['attachments'] = $mailinfo['attachments'];
$body = $mailinfo['message'];
// 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 ($mailinfo['userId']
|| strcasecmp($mailinfo['email'], $ticket->getEmail()) == 0) {
$vars['message'] = $body;
$vars['userId'] = $mailinfo['userId'] ? $mailinfo['userId'] : $ticket->getUserId();
return $ticket->postMessage($vars, 'Email');
}
// XXX: Consider collaborator role
elseif ($mailinfo['staffId']
|| ($mailinfo['staffId'] = Staff::getIdByEmail($mailinfo['email']))) {
$vars['staffId'] = $mailinfo['staffId'];
$poster = Staff::lookup($mailinfo['staffId']);
$vars['note'] = $body;
return $ticket->postNote($vars, $errors, $poster);
}
elseif (Email::getIdByEmail($mailinfo['email'])) {
// Don't process the email -- it came FROM this system
return true;
}
// Support the mail parsing system declaring a thread-type
elseif (isset($mailinfo['thread-type'])) {
switch ($mailinfo['thread-type']) {
case 'N':
$vars['note'] = $body;
$poster = $mailinfo['email'];
return $ticket->postNote($vars, $errors, $poster);
}
}
// TODO: Consider security constraints
else {
//XXX: Are we potentially leaking the email address to
// collaborators?
$vars['message'] = sprintf("Received From: %s\n\n%s",
$mailinfo['email'], $body);
$vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner?
return $ticket->postMessage($vars, 'Email');
}
// Currently impossible, but indicate that this thread object could
// not append the incoming email.
return false;
}
/* Returns file names with id as key */
function getFiles() {
$files = array();
foreach($this->getAttachments() as $attachment)
$files[$attachment['file_id']] = $attachment['name'];
return $files;
}
/* save email info
* TODO: Refactor it to include outgoing emails on responses.
*/
function saveEmailInfo($vars) {
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) {
if (!$id || !$mid)
return false;
$sql='INSERT INTO '.THREAD_ENTRY_EMAIL_TABLE
.' SET thread_entry_id='.db_input($id)
.', mid='.db_input($mid);
if ($header)
$sql .= ', headers='.db_input($header);
/* variables */
function __toString() {
return (string) $this->getBody();
return (string) $this->getBody()->display('email');
function getVar($tag) {
global $cfg;
if($tag && is_callable(array($this, 'get'.ucfirst($tag))))
return call_user_func(array($this, 'get'.ucfirst($tag)));
switch(strtolower($tag)) {
case 'create_date':
// XXX: Consider preferences of receiving user
return Format::datetime($this->getCreateDate(), true, 'UTC');
case 'update_date':
return Format::datetime($this->getUpdateDate(), true, 'UTC');
}
return false;
}
/**
* 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
$search = 'SELECT thread_entery_id, mid FROM '.THREAD_ENTRY_EMAIL_TABLE
. ' WHERE mid=%s '
. ' ORDER BY thread_entry_id DESC';
if (list($id, $mid) = db_fetch_row(db_query(
sprintf($search, db_input($mailinfo['mid']))))) {
$seen = true;
return ThreadEntry::lookup($id);
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
foreach (array_reverse($matches[0]) as $mid) {
//Try to determine if it's a reply to a tagged email.
$ref = null;
if (strpos($mid, '+')) {
list($left, $right) = explode('@',$mid);
list($left, $ref) = explode('+', $left);
$mid = "$left@$right";
}
$res = db_query(sprintf($search, db_input($mid)));
while (list($id) = db_fetch_row($res)) {
if (!($t = ThreadEntry::lookup($id)))
continue;
// 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']
&& preg_match("/\b#(\S+)/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('userId' => $user->getId(),
'ticketId' => $ticket->getId())))) {
$mailinfo['userId'] = $user->getId();
return $ticket->getLastMessage();
}
}
// Search for the message-id token in the body
if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`',
$mailinfo['message'], $match))
if ($thread = ThreadEntry::lookupByRefMessageId($match[1],
$mailinfo['email']))
return $thread;
/**
* Find a thread entry from a message-id created from the
* ::asMessageId() method
*/
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;
$thread = ThreadEntry::lookup($ids['thread']);
if (!$thread)
return false;
if (0 === strcasecmp($thread->asMessageId($from, $ver), $mid))
return $thread;
}
/**
* Get an email message-id that can be used to represent this thread
* entry. The same message-id can be passed to ::lookupByRefMessageId()
* to find this thread entry
*
* Formats:
* Initial (version <null>)
* <$:b32(thread-id)$:md5(to-addr.ticket-num.ticket-id)@:md5(url)>
* thread-id - thread-id, little-endian INT, packed
* :b32() - base32 encoded
* to-addr - individual email recipient
* ticket-num - external ticket number
* ticket-id - internal ticket id
* :md5() - last 10 hex chars of MD5 sum
* url - helpdesk URL
*/
function asMessageId($to, $version=false) {
global $ost;
$domain = md5($ost->getConfig()->getURL());
return sprintf('$%s$%s@%s',
base64_encode(pack('V', $this->getId())),
substr(md5($to . $ticket->getNumber() . $ticket->getId()), -10),
substr($domain, -10)
);
}
//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))
$sql=' INSERT INTO '.THREAD_ENTRY_TABLE.' SET `created` = NOW() '
.' ,`type` = '.db_input($vars['type'])
.' ,`thread_id` = '.db_input($vars['threadId'])
.' ,`title` = '.db_input(Format::sanitize($vars['title'], true))
.' ,`format` = '.db_input($vars['body']->getType())
.' ,`staff_id` = '.db_input($vars['staffId'])
.' ,`user_id` = '.db_input($vars['userId'])
.' ,`poster` = '.db_input($poster)