Current File : /home/escuelai/public_html/dopp/include/class.thread.php |
<?php
/*********************************************************************
class.thread.php
Ticket thread
XXX: Please DO NOT add any ticket related logic! use ticket class.
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
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');
//Ticket thread.
class Thread {
var $id; // same as ticket ID.
var $ticket;
function Thread($ticket) {
$this->ticket = $ticket;
$this->id = 0;
$this->load();
}
function load() {
if(!$this->getTicketId())
return null;
$sql='SELECT ticket.ticket_id as id '
.' ,count(DISTINCT attach.attach_id) as attachments '
." ,count(DISTINCT CASE WHEN thread.thread_type = 'M' THEN thread.id ELSE NULL END) as messages "
." ,count(DISTINCT CASE WHEN thread.thread_type = 'R' THEN thread.id ELSE NULL END) as responses "
." ,count(DISTINCT CASE WHEN thread.thread_type = 'N' THEN thread.id ELSE NULL END) as notes "
.' FROM '.TICKET_TABLE.' ticket '
.' LEFT JOIN '.TICKET_ATTACHMENT_TABLE.' attach ON ('
.'ticket.ticket_id=attach.ticket_id) '
.' LEFT JOIN '.TICKET_THREAD_TABLE.' thread ON ('
.'ticket.ticket_id=thread.ticket_id) '
.' WHERE ticket.ticket_id='.db_input($this->getTicketId())
.' GROUP BY ticket.ticket_id';
if(!($res=db_query($sql)) || !db_num_rows($res))
return false;
$this->ht = db_fetch_array($res);
$this->id = $this->ht['id'];
return true;
}
function getId() {
return $this->id;
}
function getTicketId() {
return $this->getTicket()?$this->getTicket()->getId():0;
}
function getTicket() {
return $this->ticket;
}
function getNumAttachments() {
return $this->ht['attachments'];
}
function getNumMessages() {
return $this->ht['messages'];
}
function getNumResponses() {
return $this->ht['responses'];
}
function getNumNotes() {
return $this->ht['notes'];
}
function getCount() {
return $this->getNumMessages() + $this->getNumResponses();
}
function getMessages() {
return $this->getEntries('M');
}
function getResponses() {
return $this->getEntries('R');
}
function getNotes() {
return $this->getEntries('N');
}
function getEntries($type, $order='ASC') {
if(!$order || !in_array($order, array('DESC','ASC')))
$order='ASC';
$sql='SELECT thread.*
, COALESCE(user.name,
IF(staff.staff_id,
CONCAT_WS(" ", staff.firstname, staff.lastname),
NULL)) as name '
.' ,count(DISTINCT attach.attach_id) as attachments '
.' FROM '.TICKET_THREAD_TABLE.' thread '
.' LEFT JOIN '.USER_TABLE.' user
ON (thread.user_id=user.id) '
.' LEFT JOIN '.STAFF_TABLE.' staff
ON (thread.staff_id=staff.staff_id) '
.' LEFT JOIN '.TICKET_ATTACHMENT_TABLE.' attach
ON (thread.ticket_id=attach.ticket_id
AND thread.id=attach.ref_id) '
.' WHERE thread.ticket_id='.db_input($this->getTicketId());
if($type && is_array($type))
$sql.=' AND thread.thread_type IN('.implode(',', db_input($type)).')';
elseif($type)
$sql.=' AND thread.thread_type='.db_input($type);
$sql.=' GROUP BY thread.id '
.' ORDER BY thread.created '.$order;
$entries = array();
if(($res=db_query($sql)) && db_num_rows($res)) {
while($rec=db_fetch_array($res)) {
$rec['body'] = ThreadBody::fromFormattedText($rec['body'], $rec['format']);
$entries[] = $rec;
}
}
return $entries;
}
function getEntry($id) {
return ThreadEntry::lookup($id, $this->getTicketId());
}
function addNote($vars, &$errors) {
//Add ticket Id.
$vars['ticketId'] = $this->getTicketId();
return Note::create($vars, $errors);
}
function addMessage($vars, &$errors) {
$vars['ticketId'] = $this->getTicketId();
$vars['staffId'] = 0;
return Message::create($vars, $errors);
}
function addResponse($vars, &$errors) {
$vars['ticketId'] = $this->getTicketId();
$vars['userId'] = 0;
return Response::create($vars, $errors);
}
function deleteAttachments() {
$deleted=0;
// Clear reference table
$res=db_query('DELETE FROM '.TICKET_ATTACHMENT_TABLE.' WHERE ticket_id='.db_input($this->getTicketId()));
if ($res && db_affected_rows())
$deleted = AttachmentFile::deleteOrphans();
return $deleted;
}
function delete() {
$sql = 'UPDATE '.TICKET_EMAIL_INFO_TABLE.' mid
INNER JOIN '.TICKET_THREAD_TABLE.' thread ON (thread.id = mid.thread_id)
SET mid.headers = null WHERE thread.ticket_id = '
.db_input($this->getTicketId());
db_query($sql);
$res=db_query('DELETE FROM '.TICKET_THREAD_TABLE.' WHERE ticket_id='.db_input($this->getTicketId()));
if(!$res || !db_affected_rows())
return false;
$this->deleteAttachments();
$this->removeCollaborators();
return true;
}
function removeCollaborators() {
$sql='DELETE FROM '.TICKET_COLLABORATOR_TABLE
.' WHERE ticket_id='.db_input($this->getTicketId());
return (db_query($sql) && db_affected_rows());
}
/* static */
function lookup($ticket) {
return ($ticket
&& is_object($ticket)
&& ($thread = new Thread($ticket))
&& $thread->getId()
)?$thread:null;
}
function getVar($name) {
switch ($name) {
case 'original':
return Message::firstByTicketId($this->ticket->getId())
->getBody();
break;
case 'last_message':
case 'lastmessage':
return $this->ticket->getLastMessage()->getBody();
break;
}
}
}
class ThreadEntry {
var $id;
var $ht;
var $staff;
var $ticket;
var $attachments;
function ThreadEntry($id, $type='', $ticketId=0) {
$this->load($id, $type, $ticketId);
}
function load($id=0, $type='', $ticketId=0) {
if(!$id && !($id=$this->getId()))
return false;
$sql='SELECT thread.*'
.' ,count(DISTINCT attach.attach_id) as attachments '
.' FROM '.TICKET_THREAD_TABLE.' thread '
.' LEFT JOIN '.TICKET_ATTACHMENT_TABLE.' attach
ON (thread.ticket_id=attach.ticket_id
AND thread.id=attach.ref_id) '
.' WHERE thread.id='.db_input($id);
if($type)
$sql.=' AND thread.thread_type='.db_input($type);
if($ticketId)
$sql.=' AND thread.ticket_id='.db_input($ticketId);
$sql.=' GROUP BY thread.id ';
if(!($res=db_query($sql)) || !db_num_rows($res))
return false;
$this->ht = db_fetch_array($res);
$this->id = $this->ht['id'];
$this->staff = $this->ticket = null;
$this->attachments = array();
return true;
}
function reload() {
return $this->load();
}
function getId() {
return $this->id;
}
function getPid() {
return $this->ht['pid'];
}
function getParent() {
if ($this->getPid())
return ThreadEntry::lookup($this->getPid());
}
function getType() {
return $this->ht['thread_type'];
}
function getSource() {
return $this->ht['source'];
}
function getPoster() {
return $this->ht['poster'];
}
function getTitle() {
return $this->ht['title'];
}
function getBody() {
return ThreadBody::fromFormattedText($this->ht['body'], $this->ht['format']);
}
function setBody($body) {
global $cfg;
if (!$body instanceof ThreadBody) {
if ($cfg->isHtmlThreadEnabled())
$body = new HtmlThreadBody($body);
else
$body = new TextThreadBody($body);
}
$sql='UPDATE '.TICKET_THREAD_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 getMessage() {
return $this->getBody();
}
function getCreateDate() {
return $this->ht['created'];
}
function getUpdateDate() {
return $this->ht['updated'];
}
function getNumAttachments() {
return $this->ht['attachments'];
}
function getTicketId() {
return $this->ht['ticket_id'];
}
function _deferEmailInfo() {
if (isset($this->ht['email_mid']))
return;
// Don't do this more than once
$this->ht['email_mid'] = false;
$sql = 'SELECT email_mid, headers FROM '.TICKET_EMAIL_INFO_TABLE
.' WHERE thread_id='.db_input($this->getId());
if (!($res = db_query($sql)))
return;
list($this->ht['email_mid'], $this->ht['headers']) = db_fetch_row($res);
}
function getEmailMessageId() {
$this->_deferEmailInfo();
return $this->ht['email_mid'];
}
function getEmailHeaderArray() {
require_once(INCLUDE_DIR.'class.mailparse.php');
$this->_deferEmailInfo();
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 && ($mid = $this->getEmailMessageId()))
$references .= $mid;
return $references;
}
/**
* 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;
}
function getUIDFromEmailReference($ref) {
$info = unpack('Vtid/Vuid',
Base32::decode(strtolower(substr($ref, -13))));
if ($info && $info['tid'] == $this->getId())
return $info['uid'];
}
function getTicket() {
if(!$this->ticket && $this->getTicketId())
$this->ticket = Ticket::lookup($this->getTicketId());
return $this->ticket;
}
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)) {
if (!($ticket = $this->getTicket()))
return null;
if ($ticket->getOwnerId() == $this->getUserId())
$this->user = new TicketOwner(
User::lookup($this->getUserId()), $ticket);
else
$this->user = Collaborator::lookup(array(
'userId'=>$this->getUserId(), 'ticketId'=>$this->getTicketId()));
}
return $this->user;
}
function getEmailHeader() {
$this->_deferEmailInfo();
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->getTicket()->logNote(__('File Upload Error'), $error, 'SYSTEM', false);
}
}
return $uploaded;
}
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 = $attachment['error'];
if(!$error)
$error = sprintf(_S('Unable to import attachment - %s'),$attachment['name']);
$this->getTicket()->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.
*/
function saveAttachment(&$file) {
if (is_numeric($file))
$fileId = $file;
elseif (is_array($file) && isset($file['id']))
$fileId = $file['id'];
elseif (!($fileId = AttachmentFile::save($file)))
return 0;
$inline = is_array($file) && @$file['inline'];
// TODO: Add a unique index to TICKET_ATTACHMENT_TABLE (file_id,
// ref_id), and remove this block
if ($id = db_result(db_query('SELECT attach_id FROM '.TICKET_ATTACHMENT_TABLE
.' WHERE file_id='.db_input($fileId).' AND ref_id='
.db_input($this->getId()))))
return $id;
$sql ='INSERT IGNORE INTO '.TICKET_ATTACHMENT_TABLE.' SET created=NOW() '
.' ,file_id='.db_input($fileId)
.' ,ticket_id='.db_input($this->getTicketId())
.' ,inline='.db_input($inline ? 1 : 0)
.' ,ref_id='.db_input($this->getId());
return (db_query($sql) && ($id=db_insert_id()))?$id:0;
}
function saveAttachments($files) {
$ids=array();
foreach($files as $file)
if(($id=$this->saveAttachment($file)))
$ids[] = $id;
return $ids;
}
function getAttachments() {
if($this->attachments)
return $this->attachments;
//XXX: inner join the file table instead?
$sql='SELECT a.attach_id, f.id as file_id, f.size, lower(f.`key`) as file_hash, f.`signature` as file_sig, f.name, a.inline '
.' FROM '.FILE_TABLE.' f '
.' INNER JOIN '.TICKET_ATTACHMENT_TABLE.' a ON(f.id=a.file_id) '
.' WHERE a.ticket_id='.db_input($this->getTicketId())
.' AND a.ref_id='.db_input($this->getId());
$this->attachments = array();
if(($res=db_query($sql)) && db_num_rows($res)) {
while($rec=db_fetch_array($res)) {
$rec['download_url'] = AttachmentFile::generateDownloadUrl(
$rec['file_id'], $rec['file_hash'], $rec['file_sig']);
$this->attachments[] = $rec;
}
}
return $this->attachments;
}
function getAttachmentUrls() {
$json = array();
foreach ($this->getAttachments() as $att) {
$json[$att['file_hash']] = array(
'download_url' => $att['download_url'],
'filename' => $att['name'],
);
}
return $json;
}
function getAttachmentsLinks($file='attachment.php', $target='', $separator=' ') {
$str='';
foreach($this->getAttachments() as $attachment ) {
if ($attachment['inline'])
continue;
$size = '';
if($attachment['size'])
$size=sprintf('<em>(%s)</em>', Format::file_size($attachment['size']));
$str.=sprintf('<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%s %s',
$attachment['download_url'], $target, Format::htmlchars($attachment['name']), $size, $separator);
}
return $str;
}
/**
* 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) {
global $ost;
// +==================+===================+=============+
// | 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
$msgId_info = Mailer::decodeMessageId($mailinfo['mid']);
if ($msgId_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 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'],
);
$errors = array();
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'] ?: $ticket->getUserId();
return $ticket->postMessage($vars, 'Email');
}
// Consider collaborator role (disambiguate staff members as
// collaborators)
elseif (($E = UserEmail::lookup($mailinfo['email']))
&& ($C = Collaborator::lookup(array(
'ticketId' => $ticket->getId(), 'userId' => $E->user_id
)))
) {
$vars['userId'] = $C->getUserId();
$vars['message'] = $body;
return $ticket->postMessage($vars, 'Email');
}
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?
$header = sprintf("Received From: %s <%s>\n\n", $mailinfo['name'],
$mailinfo['email']);
if ($body instanceof HtmlThreadBody)
$header = nl2br(Format::htmlchars($header));
// Add the banner to the top of the message
if ($body instanceof ThreadBody)
$body->prepend($header);
$vars['message'] = $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) {
// Don't save empty message ID
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) {
$sql='INSERT INTO '.TICKET_EMAIL_INFO_TABLE
.' SET thread_id='.db_input($id)
.', email_mid='.db_input($mid); //TODO: change it to message_id.
if ($header)
$sql .= ', headers='.db_input($header);
return db_query($sql)?db_insert_id():0;
}
/* variables */
function __toString() {
return (string) $this->getBody();
}
function asVar() {
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':
return Format::date(
$cfg->getDateTimeFormat(),
Misc::db2gmtime($this->getCreateDate()),
$cfg->getTZOffset(),
$cfg->observeDaylightSaving());
break;
case 'update_date':
return Format::date(
$cfg->getDateTimeFormat(),
Misc::db2gmtime($this->getUpdateDate()),
$cfg->getTZOffset(),
$cfg->observeDaylightSaving());
break;
}
return false;
}
/* static calls */
function lookup($id, $tid=0, $type='') {
return ($id
&& is_numeric($id)
&& ($e = new ThreadEntry($id, $type, $tid))
&& $e->getId()==$id
)?$e:null;
}
/**
* 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_id, email_mid FROM '.TICKET_EMAIL_INFO_TABLE
. ' WHERE email_mid=%s ORDER BY thread_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
// 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
$thread = null;
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;
}
}
// 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['threadId']
&& ($t = ThreadEntry::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;
}
}
// 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('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('`(?:class="mid-|Ref-Mid: )([^"\s]*)(?:$|")`',
$mailinfo['message'], $match))
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
*/
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());
$ticket = $this->getTicket();
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.
function create($vars) {
global $cfg;
//Must have...
if(!$vars['ticketId'] || !$vars['type'] || !in_array($vars['type'], array('M','R','N')))
return false;
if (!$vars['body'] instanceof ThreadBody) {
if ($cfg->isHtmlThreadEnabled())
$vars['body'] = new HtmlThreadBody($vars['body']);
else
$vars['body'] = new TextThreadBody($vars['body']);
}
// Drop stripped 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 ThreadBody
// 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))
$poster = (string) $poster;
$sql=' INSERT INTO '.TICKET_THREAD_TABLE.' SET created=NOW() '
.' ,thread_type='.db_input($vars['type'])
.' ,ticket_id='.db_input($vars['ticketId'])
.' ,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)
.' ,source='.db_input($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)
$sql.=' ,body='.db_input($body);
if(isset($vars['pid']))
$sql.=' ,pid='.db_input($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)
$sql.=' ,pid='.db_input($vars['reply_to']->getId());
if($vars['ip_address'])
$sql.=' ,ip_address='.db_input($vars['ip_address']);
//echo $sql;
if(!db_query($sql) || !($entry=self::lookup(db_insert_id(), $vars['ticketId'])))
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']);
foreach ($vars['attachments'] as $a) {
// 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);
}
}
$sql = 'UPDATE '.TICKET_THREAD_TABLE.' SET body='.db_input($body)
.' WHERE `id`='.db_input($entry->getId());
if (!db_query($sql) || !db_affected_rows())
return false;
// Set the $entry here for search indexing
$entry->ht['body'] = $body;
}
// Email message id
$entry->saveEmailInfo($vars);
// Inline images (attached to the draft)
$entry->saveAttachments(Draft::getAttachmentIds($body));
Signal::send('model.created', $entry);
return $entry;
}
function add($vars) {
return ($entry=self::create($vars))?$entry->getId():0;
}
}
/* Message - Ticket thread entry of type message */
class Message extends ThreadEntry {
function Message($id, $ticketId=0) {
parent::ThreadEntry($id, 'M', $ticketId);
}
function getSubject() {
return $this->getTitle();
}
function create($vars, &$errors) {
return self::lookup(self::add($vars, $errors));
}
function add($vars, &$errors) {
if(!$vars || !is_array($vars) || !$vars['ticketId'])
$errors['err'] = __('Missing or invalid data');
elseif(!$vars['message'])
$errors['message'] = __('Message content is required');
if($errors) return false;
$vars['type'] = 'M';
$vars['body'] = $vars['message'];
if (!$vars['poster']
&& $vars['userId']
&& ($user = User::lookup($vars['userId'])))
$vars['poster'] = (string) $user->getName();
return ThreadEntry::add($vars);
}
function lookup($id, $tid=0, $type='M') {
return ($id
&& is_numeric($id)
&& ($m = new Message($id, $tid))
&& $m->getId()==$id
)?$m:null;
}
function lastByTicketId($ticketId) {
return self::byTicketId($ticketId);
}
function firstByTicketId($ticketId) {
return self::byTicketId($ticketId, false);
}
function byTicketId($ticketId, $last=true) {
$sql=' SELECT thread.id FROM '.TICKET_THREAD_TABLE.' thread '
.' WHERE thread_type=\'M\' AND thread.ticket_id = '.db_input($ticketId)
.sprintf(' ORDER BY thread.id %s LIMIT 1', $last ? 'DESC' : 'ASC');
if (($res = db_query($sql)) && ($id = db_result($res)))
return Message::lookup($id);
return null;
}
}
/* Response - Ticket thread entry of type response */
class Response extends ThreadEntry {
function Response($id, $ticketId=0) {
parent::ThreadEntry($id, 'R', $ticketId);
}
function getSubject() {
return $this->getTitle();
}
function getRespondent() {
return $this->getStaff();
}
function create($vars, &$errors) {
return self::lookup(self::add($vars, $errors));
}
function add($vars, &$errors) {
if(!$vars || !is_array($vars) || !$vars['ticketId'])
$errors['err'] = __('Missing or invalid data');
elseif(!$vars['response'])
$errors['response'] = __('Response content is required');
if($errors) return false;
$vars['type'] = 'R';
$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 ThreadEntry::add($vars);
}
function lookup($id, $tid=0, $type='R') {
return ($id
&& is_numeric($id)
&& ($r = new Response($id, $tid))
&& $r->getId()==$id
)?$r:null;
}
}
/* Note - Ticket thread entry of type note (Internal Note) */
class Note extends ThreadEntry {
function Note($id, $ticketId=0) {
parent::ThreadEntry($id, 'N', $ticketId);
}
/* static */
function create($vars, &$errors) {
return self::lookup(self::add($vars, $errors));
}
function add($vars, &$errors) {
//Check required params.
if(!$vars || !is_array($vars) || !$vars['ticketId'])
$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'] = 'N';
$vars['body'] = $vars['note'];
return ThreadEntry::add($vars);
}
function lookup($id, $tid=0, $type='N') {
return ($id
&& is_numeric($id)
&& ($n = new Note($id, $tid))
&& $n->getId()==$id
)?$n:null;
}
}
class ThreadBody /* extends SplString */ {
static $types = array('text', 'html');
var $body;
var $type;
var $stripped_images = array();
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 ThreadBody type");
$this->body = (string) $body;
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) . ' ... '
. _S('(truncated)');
}
$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 ThreadBody(sprintf('<pre>%s</pre>',
Format::htmlchars($this->body)), $type);
case 'html:text':
return new ThreadBody(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,
PREG_PATTERN_ORDER);
// Strip the quoted part of the body
if ((list($msg) = explode($tag, $this->body, 2)) && trim($msg)) {
$this->body = $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() {
switch ($this->type) {
case 'html':
return trim($this->body, " <>br/\t\n\r") ? $this->body: '';
case 'text':
return trim($this->body) ? $this->body: '';
default:
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');
}
function getSearchable() {
return Format::searchable($this->body);
}
static function fromFormattedText($text, $format=false) {
switch ($format) {
case 'text':
return new TextThreadBody($text);
case 'html':
return new HtmlThreadBody($text, array('strip-embedded'=>false));
default:
return new ThreadBody($text);
}
}
static function clean($text, $format=null) {
global $cfg;
$format = $format ?: ($cfg->isHtmlThreadEnabled() ? 'html' : 'text');
$body = static::fromFormattedText($text, $format);
return $body->getClean();
}
}
class TextThreadBody extends ThreadBody {
function __construct($body, $options=array()) {
parent::__construct($body, 'text', $options);
}
function getClean() {
return Format::stripEmptyLines(parent::getClean());
}
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>';
case 'email':
return '<div style="white-space:pre-wrap">'
.$escaped.'</div>';
case 'pdf':
return nl2br($escaped);
default:
return '<pre>'.$escaped.'</pre>';
}
}
}
class HtmlThreadBody extends ThreadBody {
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 Format::sanitize(parent::getClean());
}
function getSearchable() {
// Replace tag chars with spaces (to ensure words are separated)
$body = Format::html($this->body, array('hook_tag' => function($el, $attributes=0) {
static $non_ws = array('wbr' => 1);
return (isset($non_ws[$el])) ? '' : ' ';
}));
// Collapse multiple white-spaces
$body = html_entity_decode($body, ENT_QUOTES);
$body = preg_replace('`\s+`u', ' ', $body);
return Format::searchable($body);
}
function display($output=false) {
if ($this->isEmpty())
return '(empty)';
switch ($output) {
case 'email':
return $this->body;
case 'pdf':
return Format::clickableurls($this->body);
default:
return Format::display($this->body);
}
}
}
?>