diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php index 8d7228dea69f4c74eb6eb464be1cf6bcdf5aa766..9933fb28abdc38e645392e6e0c5c152aa2306d8f 100644 --- a/include/ajax.tickets.php +++ b/include/ajax.tickets.php @@ -785,9 +785,11 @@ class TicketsAjaxAPI extends AjaxController { } function triggerThreadAction($ticket_id, $thread_id, $action) { - $thread = ThreadEntry::lookup($thread_id, $ticket_id); + $thread = ThreadEntry::lookup($thread_id); if (!$thread) Http::response(404, 'No such ticket thread entry'); + if ($thread->getThread()->getObjectId() != $ticket_id) + Http::response(404, 'No such ticket thread entry'); $valid = false; foreach ($thread->getActions() as $group=>$list) { diff --git a/include/api.tickets.php b/include/api.tickets.php index d80b1582889f7bf3f944eea9808a3f675f6008f2..d70eda638a31f69bc9841d47d0714c127232a9ef 100644 --- a/include/api.tickets.php +++ b/include/api.tickets.php @@ -80,7 +80,8 @@ class TicketApiController extends ApiController { } // Validate and save immediately try { - $file['id'] = $fileField->uploadAttachment($file); + $F = $fileField->uploadAttachment($file); + $file['id'] = $F->getId(); } catch (FileUploadError $ex) { $file['error'] = $file['name'] . ': ' . $ex->getMessage(); @@ -150,14 +151,29 @@ class TicketApiController extends ApiController { if (!$data) $data = $this->getEmailRequest(); - if (($thread = ThreadEntry::lookupByEmailHeaders($data)) - && ($t=$thread->getTicket()) - && ($data['staffId'] - || !$t->isClosed() - || $t->isReopenable()) - && $thread->postEmail($data)) { - return $thread->getTicket(); + $seen = false; + if (($entry = ThreadEntry::lookupByEmailHeaders($data, $seen)) + && ($message = $entry->postEmail($data)) + ) { + if ($message instanceof ThreadEntry) { + return $message->getThread()->getObject(); + } + else if ($seen) { + // Email has been processed previously + return $entry->getThread()->getObject(); + } } + + // Allow continuation of thread without initial message or note + elseif (($thread = Thread::lookupByEmailHeaders($data)) + && ($message = $thread->postEmail($data)) + ) { + return $thread->getObject(); + } + + // All emails which do not appear to be part of an existing thread + // will always create new "Tickets". All other objects will need to + // be created via the web interface or the API return $this->createTicket($data); } diff --git a/include/class.attachment.php b/include/class.attachment.php index 9126cff8c0c7f9f68b6071dae3c50dc09e38c7ca..bb208411157836127f7ef5f9622f2499c9d8b39e 100644 --- a/include/class.attachment.php +++ b/include/class.attachment.php @@ -16,36 +16,37 @@ require_once(INCLUDE_DIR.'class.ticket.php'); require_once(INCLUDE_DIR.'class.file.php'); -class Attachment { - var $id; - var $file_id; +class Attachment extends VerySimpleModel { + static $meta = array( + 'table' => ATTACHMENT_TABLE, + 'pk' => array('id'), + 'select_related' => array('file'), + 'joins' => array( + 'thread_entry' => array( + 'constraint' => array( + 'object_id' => 'ThreadEntry.id', + 'type' => "'H'", + ), + ), + 'file' => array( + 'constraint' => array( + 'file_id' => 'AttachmentFile.id', + ), + ), + ), + ); - var $ht; var $object; - function Attachment($id) { - - $sql = 'SELECT a.* FROM '.ATTACHMENT_TABLE.' a ' - . 'WHERE a.id='.db_input($id); - if (!($res=db_query($sql)) || !db_num_rows($res)) - return; - - $this->ht = db_fetch_array($res); - $this->file = $this->object = null; - } - function getId() { - return $this->ht['id']; + return $this->id; } function getFileId() { - return $this->ht['file_id']; + return $this->file_id; } function getFile() { - if(!$this->file && $this->getFileId()) - $this->file = AttachmentFile::lookup($this->getFileId()); - return $this->file; } @@ -66,44 +67,23 @@ class Attachment { return $this->object; } - static function getIdByFileHash($hash, $objectId=0) { - $sql='SELECT a.id FROM '.ATTACHMENT_TABLE.' a ' - .' INNER JOIN '.FILE_TABLE.' f ON(f.id=a.file_id) ' - .' WHERE f.`key`='.db_input($hash); + static function lookupByFileHash($hash, $objectId=0) { + $file = static::objects() + ->filter(array('file__key' => $hash)); + if ($objectId) - $sql.=' AND a.object_id='.db_input($objectId); + $file->filter(array('object_id' => $objectId)); - return db_result(db_query($sql)); + return $file->first(); } static function lookup($var, $objectId=0) { - - $id = is_numeric($var) ? $var : self::getIdByFileHash($var, - $objectId); - - return ($id - && is_numeric($id) - && ($attach = new Attachment($id, $objectId)) - && $attach->getId()==$id - ) ? $attach : null; + return is_numeric($var) + ? parent::lookup($var) + : static::lookupByFileHash($var, $objectId); } } -class AttachmentModel extends VerySimpleModel { - static $meta = array( - 'table' => ATTACHMENT_TABLE, - 'pk' => array('id'), - 'joins' => array( - 'thread' => array( - 'constraint' => array( - 'object_id' => 'ThreadEntryModel.id', - 'type' => "'H'", - ), - ), - ), - ); -} - class GenericAttachment extends VerySimpleModel { static $meta = array( 'table' => ATTACHMENT_TABLE, @@ -132,24 +112,26 @@ class GenericAttachments { $fileId = $file; elseif (is_array($file) && isset($file['id'])) $fileId = $file['id']; - elseif (!($fileId = AttachmentFile::upload($file))) + elseif ($F = AttachmentFile::upload($file)) + $fileId = $F->getId(); + else continue; $_inline = isset($file['inline']) ? $file['inline'] : $inline; - $sql ='INSERT INTO '.ATTACHMENT_TABLE - .' SET `type`='.db_input($this->getType()) - .',object_id='.db_input($this->getId()) - .',file_id='.db_input($fileId) - .',inline='.db_input($_inline ? 1 : 0); + $att = Attachment::create(array( + 'type' => $this->getType(), + 'object_id' => $this->getId(), + 'file_id' => $fileId, + 'inline' => $_inline ? 1 : 0, + )); if ($lang) - $sql .= ',lang='.db_input($lang); + $att->lang = $lang; // File may already be associated with the draft (in the // event it was deleted and re-added) - if (db_query($sql, function($errno) { return $errno != 1062; }) - || db_errno() == 1062) - $i[] = $fileId; + $att->save(); + $i[] = $fileId; } return $i; @@ -161,15 +143,18 @@ class GenericAttachments { $fileId = $file; elseif (is_array($file) && isset($file['id'])) $fileId = $file['id']; - elseif (!($fileId = AttachmentFile::save($file))) + elseif ($file = AttachmentFile::create($file)) + $fileId = $file->getId(); + else return false; - $sql ='INSERT INTO '.ATTACHMENT_TABLE - .' SET `type`='.db_input($this->getType()) - .',object_id='.db_input($this->getId()) - .',file_id='.db_input($fileId) - .',inline='.db_input($inline ? 1 : 0); - if (!db_query($sql) || !db_affected_rows()) + $att = Attachment::create(array( + 'type' => $this->getType(), + 'object_id' => $this->getId(), + 'file_id' => $fileId, + 'inline' => $inline ? 1 : 0, + )); + if (!$att->save()) return false; return $fileId; @@ -181,51 +166,29 @@ class GenericAttachments { function count($lang=false) { return count($this->getSeparates($lang)); } function _getList($separate=false, $inlines=false, $lang=false) { - if(!isset($this->attachments)) { - $this->attachments = array(); - $sql='SELECT f.id, f.size, f.`key`, f.signature, f.name ' - .', a.inline, a.lang, a.id as attach_id ' - .' FROM '.FILE_TABLE.' f ' - .' INNER JOIN '.ATTACHMENT_TABLE.' a ON(f.id=a.file_id) ' - .' WHERE a.`type`='.db_input($this->getType()) - .' AND a.object_id='.db_input($this->getId()); - if(($res=db_query($sql)) && db_num_rows($res)) { - while($rec=db_fetch_array($res)) { - $rec['download_url'] = AttachmentFile::generateDownloadUrl( - $rec['id'], $rec['key'], $rec['signature']); - $this->attachments[] = $rec; - } - } - } - $attachments = array(); - foreach ($this->attachments as $a) { - if (($a['inline'] != $separate || $a['inline'] == $inlines) - && $lang == $a['lang']) { - $a['file_id'] = $a['id']; - $a['hash'] = md5($a['file_id'].session_id().$a['key']); - $attachments[] = $a; - } - } - return $attachments; + return Attachment::objects()->filter(array( + 'type' => $this->getType(), + 'object_id' => $this->getId(), + )); } function delete($file_id) { - $deleted = 0; - $sql='DELETE FROM '.ATTACHMENT_TABLE - .' WHERE object_id='.db_input($this->getId()) - .' AND `type`='.db_input($this->getType()) - .' AND file_id='.db_input($file_id); - return db_query($sql) && db_affected_rows() > 0; + return Attachment::objects()->filter(array( + 'type' => $this->getType(), + 'object_id' => $this->getId(), + 'file_id' => $file_id, + ))->delete(); } function deleteAll($inline_only=false){ - $deleted=0; - $sql='DELETE FROM '.ATTACHMENT_TABLE - .' WHERE object_id='.db_input($this->getId()) - .' AND `type`='.db_input($this->getType()); + $objects = Attachment::objects()->filter(array( + 'type' => $this->getType(), + 'object_id' => $this->getId(), + )); if ($inline_only) - $sql .= ' AND inline = 1'; - return db_query($sql) && db_affected_rows() > 0; + $objects->filter(array('inline' => 1)); + + return $objects->delete(); } function deleteInlines() { diff --git a/include/class.client.php b/include/class.client.php index e353b977aaab06a3c3667b5d7f8e053987e120b1..28b4d8df0de7efa2f50be20a093e17d597915be8 100644 --- a/include/class.client.php +++ b/include/class.client.php @@ -15,7 +15,8 @@ **********************************************************************/ require_once INCLUDE_DIR.'class.user.php'; -abstract class TicketUser { +abstract class TicketUser +implements EmailContact { static private $token_regex = '/^(?P<type>\w{1})(?P<algo>\d+)x(?P<hash>.*)$/i'; @@ -54,6 +55,9 @@ abstract class TicketUser { } + function getId() { return ($this->user) ? $this->user->getId() : null; } + function getEmail() { return ($this->user) ? $this->user->getEmail() : null; } + function sendAccessLink() { global $ost; @@ -427,4 +431,7 @@ class ClientAccount extends UserAccount { } } +// Used by the email system +interface EmailContact { +} ?> diff --git a/include/class.config.php b/include/class.config.php index fc488ee4619d4e5fdc8edf5761f8253546e07a18..b8c35095396f5866b47e79b023338ce2289794a9 100644 --- a/include/class.config.php +++ b/include/class.config.php @@ -1111,7 +1111,7 @@ class OsticketConfig extends Config { ; // Pass elseif ($logo['error']) $errors['logo'] = $logo['error']; - elseif (!($id = AttachmentFile::uploadLogo($logo, $error))) + elseif (!AttachmentFile::uploadLogo($logo, $error)) $errors['logo'] = sprintf(__('Unable to upload logo image: %s'), $error); } diff --git a/include/class.draft.php b/include/class.draft.php index 49503ed7c66bcc367580ab55779cc109ad4be07f..659325ede98dd6281a83857f92d5b4f989f9df96 100644 --- a/include/class.draft.php +++ b/include/class.draft.php @@ -62,11 +62,13 @@ class Draft extends VerySimpleModel { $body = Format::localizeInlineImages($body); $matches = array(); if (preg_match_all('/"cid:([\\w.-]{32})"/', $body, $matches)) { - foreach ($matches[1] as $hash) { - if ($file_id = AttachmentFile::getIdByHash($hash)) - $attachments[] = array( - 'id' => $file_id, - 'inline' => true); + $files = AttachmentFile::objects() + ->filter(array('key__in' => $matches[1])); + foreach ($files as $F) { + $attachments[] = array( + 'id' => $F->getId(), + 'inline' => true + ); } } return $attachments; @@ -86,9 +88,12 @@ class Draft extends VerySimpleModel { // Purge current attachments $this->attachments->deleteInlines(); - foreach ($matches[1] as $hash) - if ($file = AttachmentFile::getIdByHash($hash)) - $this->attachments->upload($file, true); + foreach (AttachmentFile::objects() + ->filter(array('key__in' => $matches[1])) + as $F + ) { + $this->attachments->upload($F->getId(), true); + } } function setBody($body) { diff --git a/include/class.file.php b/include/class.file.php index 2c2ab0fc69a37f19c3cda28e49b2cfa22d7bd925..a540aebd2b986f4ebe8df6881303e8992f33f30c 100644 --- a/include/class.file.php +++ b/include/class.file.php @@ -14,43 +14,17 @@ require_once(INCLUDE_DIR.'class.signal.php'); require_once(INCLUDE_DIR.'class.error.php'); -class AttachmentFile { - - var $id; - var $ht; - - function AttachmentFile($id) { - $this->id =0; - return ($this->load($id)); - } - - function load($id=0) { - - if(!$id && !($id=$this->getId())) - return false; - - $sql='SELECT f.id, f.type, size, name, `key`, signature, ft, bk, f.created, ' - .' count(DISTINCT a.object_id) as canned, ' - .' count(DISTINCT t.id) as entries ' - .' FROM '.FILE_TABLE.' f ' - .' LEFT JOIN '.ATTACHMENT_TABLE.' a - ON(a.file_id=f.id) ' - .' LEFT JOIN '.ATTACHMENT_TABLE.' t - ON(t.file_id = f.id) ' - .' WHERE f.id='.db_input($id) - .' GROUP BY f.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 reload() { - return $this->load(); - } +class AttachmentFile extends VerySimpleModel { + + static $meta = array( + 'table' => FILE_TABLE, + 'pk' => array('id'), + 'joins' => array( + 'attachments' => array( + 'reverse' => 'Attachment.file' + ), + ), + ); function getHashtable() { return $this->ht; @@ -61,15 +35,15 @@ class AttachmentFile { } function getNumEntries() { - return $this->ht['entries']; + return $this->attachments->count(); } function isCanned() { - return ($this->ht['canned']); + return $this->getNumEntries(); } function isInUse() { - return ($this->getNumEntries() || $this->isCanned()); + return $this->getNumEntries(); } function getId() { @@ -77,11 +51,11 @@ class AttachmentFile { } function getType() { - return $this->ht['type']; + return $this->type; } function getBackend() { - return $this->ht['bk']; + return $this->bk; } function getMime() { @@ -89,25 +63,23 @@ class AttachmentFile { } function getSize() { - return $this->ht['size']; + return $this->size; } function getName() { - return $this->ht['name']; + return $this->name; } function getKey() { - return $this->ht['key']; + return $this->key; } function getSignature() { - $sig = $this->ht['signature']; - if (!$sig) return $this->getKey(); - return $sig; + return $this->signature ?: $this->getKey(); } function lastModified() { - return $this->ht['created']; + return $this->created; } function open() { @@ -145,8 +117,7 @@ class AttachmentFile { function delete() { - $sql='DELETE FROM '.FILE_TABLE.' WHERE id='.db_input($this->getId()).' LIMIT 1'; - if(!db_query($sql) || !db_affected_rows()) + if (!parent::delete()) return false; if ($bk = $this->open()) @@ -298,7 +269,7 @@ class AttachmentFile { } /* Function assumes the files types have been validated */ - function upload($file, $ft='T') { + static function upload($file, $ft='T') { if(!$file['name'] || $file['error'] || !is_uploaded_file($file['tmp_name'])) return false; @@ -314,10 +285,10 @@ class AttachmentFile { 'tmp_name'=>$file['tmp_name'], ); - return AttachmentFile::save($info, $ft); + return static::create($info, $ft); } - function uploadLogo($file, &$error, $aspect_ratio=3) { + static function uploadLogo($file, &$error, $aspect_ratio=3) { /* Borrowed in part from * http://salman-w.blogspot.com/2009/04/crop-to-fit-image-using-aspphp.html */ @@ -348,8 +319,13 @@ class AttachmentFile { return false; } - function save(&$file, $ft='T') { - + static function create(&$file, $ft='T') { + if (isset($file['encoding'])) { + switch ($file['encoding']) { + case 'base64': + $file['data'] = base64_decode($file['data']); + } + } if (isset($file['data'])) { // Allow a callback function to delay or avoid reading or // fetching ihe file contents @@ -364,15 +340,18 @@ class AttachmentFile { if (isset($file['size'])) { // Check and see if the file is already on record - $sql = 'SELECT id, `key` FROM '.FILE_TABLE - .' WHERE signature='.db_input($file['signature']) - .' AND size='.db_input($file['size']); - - // If the record exists in the database already, a file with the - // same hash and size is already on file -- just return its ID - if (list($id, $key) = db_fetch_row(db_query($sql))) { - $file['key'] = $key; - return $id; + $existing = static::objects()->filter(array( + 'signature' => $file['signature'], + 'size' => $file['size'] + )) + ->first(); + + // If the record exists in the database already, a file with + // the same hash and size is already on file -- just return + // the file + if ($existing) { + $file['key'] = $existing->key; + return $existing; } } elseif (!isset($file['data'])) { @@ -393,20 +372,19 @@ class AttachmentFile { if (!$file['type']) $file['type'] = 'application/octet-stream'; - $sql='INSERT INTO '.FILE_TABLE.' SET created=NOW() ' - .',type='.db_input(strtolower($file['type'])) - .',name='.db_input($file['name']) - .',`key`='.db_input($file['key']) - .',ft='.db_input($ft ?: 'T') - .',signature='.db_input($file['signature']); - if (isset($file['size'])) - $sql .= ',size='.db_input($file['size']); + $f = parent::create(array( + 'type' => strtolower($file['type']), + 'name' => $file['name'], + 'key' => $file['key'], + 'ft' => $ft ?: 'T', + 'signature' => $file['signature'], + )); - if (!(db_query($sql) && ($id = db_insert_id()))) - return false; + if (isset($file['size'])) + $f->size = $file['size']; - if (!($f = AttachmentFile::lookup($id))) + if (!$f->save()) return false; // Note that this is preferred over $f->open() because the file does @@ -440,26 +418,22 @@ class AttachmentFile { return false; } - $sql = 'UPDATE '.FILE_TABLE.' SET bk='.db_input($bk->getBkChar()); + $f->bk = $bk->getBkChar(); if (!isset($file['size'])) { if ($size = $bk->getSize()) - $file['size'] = $size; + $f->size = $size; // Prefer mb_strlen, because mbstring.func_overload will // automatically prefer it if configured. elseif (extension_loaded('mbstring')) - $file['size'] = mb_strlen($file['data'], '8bit'); + $f->size = mb_strlen($file['data'], '8bit'); // bootstrap.php include a compat version of mb_strlen else - $file['size'] = strlen($file['data']); - - $sql .= ', `size`='.db_input($file['size']); + $f->size = strlen($file['data']); } - $sql .= ' WHERE id='.db_input($f->getId()); - db_query($sql); - - return $f->getId(); + $f->save(); + return $f; } /** @@ -523,10 +497,8 @@ class AttachmentFile { return false; } - $sql = 'UPDATE '.FILE_TABLE.' SET bk=' - .db_input($target->getBkChar()) - .' WHERE id='.db_input($this->getId()); - if (!db_query($sql) || db_affected_rows()!=1) + $this->bk = $target->getBkChar(); + if (!$this->save()) return false; return $source->unlink(); @@ -554,31 +526,14 @@ class AttachmentFile { return FileStorageBackend::lookup($char, $file); } - /* Static functions */ - function getIdByHash($hash) { - - $sql='SELECT id FROM '.FILE_TABLE.' WHERE `key`='.db_input($hash); - if(($res=db_query($sql)) && db_num_rows($res)) - list($id)=db_fetch_row($res); - - return $id; - } - - function lookup($id) { - - $id = is_numeric($id)?$id:AttachmentFile::getIdByHash($id); - - return ($id && ($file = new AttachmentFile($id)) && $file->getId()==$id)?$file:null; + static function lookupByHash($hash) { + return parent::lookup(array('key' => $hash)); } - static function create($info, &$errors) { - if (isset($info['encoding'])) { - switch ($info['encoding']) { - case 'base64': - $info['data'] = base64_decode($info['data']); - } - } - return self::save($info); + static function lookup($id) { + return is_numeric($id) + ? parent::lookup($id) + : static::lookupByHash($id); } /* @@ -620,35 +575,31 @@ class AttachmentFile { * Removes files and associated meta-data for files which no ticket, * canned-response, or faq point to any more. */ - /* static */ function deleteOrphans() { + static function deleteOrphans() { // XXX: Allow plugins to define filetypes which do not represent // files attached to tickets or other things in the attachment // table and are not logos - //FIXME: Just user straight up left join - $sql = 'SELECT id FROM '.FILE_TABLE.' WHERE id NOT IN (' - .'SELECT file_id FROM '.ATTACHMENT_TABLE - .") AND `ft` = 'T' AND TIMESTAMPDIFF(DAY, `created`, CURRENT_TIMESTAMP) > 1"; - - if (!($res = db_query($sql))) - return false; - - while (list($id) = db_fetch_row($res)) - if (($file = self::lookup($id)) && !$file->delete()) + $files = static::objects() + ->filter(array( + 'attachments__object_id__isnull' => true, + 'ft' => 'T', + 'created__gt' => new DateTime('now -1 day'), + )); + + foreach ($files as $f) { + if (!$f->delete()) break; + } return true; } /* static */ function allLogos() { - $sql = 'SELECT id FROM '.FILE_TABLE.' WHERE ft="L" - ORDER BY created'; - $logos = array(); - $res = db_query($sql); - while (list($id) = db_fetch_row($res)) - $logos[] = AttachmentFile::lookup($id); - return $logos; + return static::objects() + ->filter(array('ft' => 'L')) + ->order_by('created'); } } diff --git a/include/class.format.php b/include/class.format.php index 7c06ee1c464dd85db22afb8d2fabcf5f4a71e360..f922a6c5ac3141f505c4aee36818063974637fe6 100644 --- a/include/class.format.php +++ b/include/class.format.php @@ -410,10 +410,19 @@ class Format { function viewableImages($html, $script=false) { + $cids = $images = array(); + // Try and get information for all the files in one query + if (preg_match_all('/"cid:([\w._-]{32})"/', $html, $cids)) { + foreach (AttachmentFile::objects() + ->filter(array('key__in' => $cids[1])) + as $file + ) { + $images[strtolower($file->getKey())] = $file; + } + } return preg_replace_callback('/"cid:([\w._-]{32})"/', - function($match) use ($script) { - $hash = $match[1]; - if (!($file = AttachmentFile::lookup($hash))) + function($match) use ($script, $images) { + if (!($file = $images[strtolower($match[1])])) return $match[0]; return sprintf('"%s" data-cid="%s"', $file->getDownloadUrl(false, 'inline', $script), $match[1]); diff --git a/include/class.forms.php b/include/class.forms.php index dfccf962c5d1e307fec038fd028f400c889de122..5122ddd933bacf16c1bf3a91e46ca377067f48bd 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -1929,10 +1929,10 @@ class FileUploadField extends FormField { if (!$bypass && $file['size'] > $config['size']) Http::response(413, 'File is too large'); - if (!($id = AttachmentFile::upload($file))) + if (!($F = AttachmentFile::upload($file))) Http::response(500, 'Unable to store file: '. $file['error']); - return $id; + return $F->getId(); } /** @@ -1972,10 +1972,10 @@ class FileUploadField extends FormField { if ($file['size'] > $config['size']) throw new FileUploadError(__('File size is too large')); - if (!$id = AttachmentFile::save($file)) + if (!$F = AttachmentFile::create($file)) throw new FileUploadError(__('Unable to save file')); - return $id; + return $F; } function isValidFileType($name, $type=false) { @@ -2718,7 +2718,8 @@ class FileUploadWidget extends Widget { if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_FILES[$this->name])) { foreach (AttachmentFile::format($_FILES[$this->name]) as $file) { try { - $ids[] = $this->field->uploadFile($file); + $F = $this->field->uploadFile($file); + $ids[] = $F->getId(); } catch (FileUploadError $ex) {} } diff --git a/include/class.mailer.php b/include/class.mailer.php index a3093c203a14eac9f8e7a2082830f715352b2128..e3b40c6b2483de759a8a251aed9344f813a45039 100644 --- a/include/class.mailer.php +++ b/include/class.mailer.php @@ -79,15 +79,202 @@ class Mailer { return $this->attachments; } - function addAttachment($attachment) { + function addAttachment(Attachment $attachment) { // XXX: This looks too assuming; however, the attachment processor // in the ::send() method seems hard coded to expect this format - $this->attachments[$attachment['file_id']] = $attachment; + $this->attachments[$attachment->file_id] = $attachment->file; + } + + function addFile(AttachmentFile $file) { + // XXX: This looks too assuming; however, the attachment processor + // in the ::send() method seems hard coded to expect this format + $this->attachments[$file->file_id] = $file; } function addAttachments($attachments) { - foreach ($attachments as $a) - $this->addAttachment($a); + foreach ($attachments as $a) { + if ($a instanceof Attachment) + $this->addAttachment($a); + elseif ($a instanceof AttachmentFile) + $this->addFile($a); + } + } + + /** + * getMessageId + * + * Generates a unique message ID for an outbound message. Optionally, + * the recipient can be used to create a tag for the message ID where + * the user-id and thread-entry-id are encoded in the message-id so + * the message can be threaded if it is replied to without any other + * indicator of the thread to which it belongs. This tag is signed with + * the secret-salt of the installation to guard against false positives. + * + * Parameters: + * $recipient - (EmailContact|null) recipient of the message. The ID of + * the recipient is placed in the message id TAG section so it can + * be recovered if the email replied to directly by the end user. + * $options - (array) - options passed to ::send(). If it includes a + * 'thread' element, the threadId will be recorded in the TAG + * + * Returns: + * (string) - email message id, without leading and trailing <> chars. + * See the Format below for the structure. + * + * Format: + * VA-B-C, with dash separators and A-C explained below: + * + * V: Version code of the generated Message-Id + * A: Predictable random code — used for loop detection (sysid) + * B: Random data for unique identifier (rand) + * C: TAG: Base64(Pack(userid, entryId, threadId, type, Signature)), + * '=' chars discarded + * where Signature is: + * Signed Tag value, last 5 chars from + * HMAC(sha1, Tag + rand + sysid, SECRET_SALT), + * where Tag is: + * pack(userId, entryId, threadId, type) + */ + function getMessageId($recipient, $options=array(), $version='B') { + $tag = ''; + $rand = Misc::randCode(9, + // RFC822 specifies the LHS of the addr-spec can have any char + // except the specials — ()<>@,;:\".[], dash is reserved as the + // section separator, and + is reserved for historical reasons + 'abcdefghiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_='); + $sig = $this->getEmail()?$this->getEmail()->getEmail():'@osTicketMailer'; + $sysid = static::getSystemMessageIdCode(); + if ($recipient instanceof EmailContact) { + // Create a tag for the outbound email + $entry = (isset($options['thread']) && $options['thread'] instanceof ThreadEntry) + ? $options['thread'] : false; + $thread = $entry ? $entry->getThread() + : (isset($options['thread']) && $options['thread'] instanceof Thread + ? $options['thread'] : false); + $tag = pack('VVVa', + $recipient->getId(), + $entry ? $entry->getId() : 0, + $thread ? $thread->getId() : 0, + ($recipient instanceof Staff ? 'S' + : ($recipient instanceof TicketOwner ? 'U' + : ($recipient instanceof Collaborator ? 'C' + : '?'))) + ); + // Sign the tag with the system secret salt + $tag .= substr(hash_hmac('sha1', $tag.$rand.$sysid, SECRET_SALT, true), -5); + $tag = str_replace('=','',base64_encode($tag)); + } + return sprintf('B%s-%s-%s-%s', + $sysid, $rand, $tag, $sig); + } + + /** + * decodeMessageId + * + * Decodes a message-id generated by osTicket using the ::getMessageId() + * method of this class. This will digest the received message-id token + * and return an array with some information about it. + * + * Parameters: + * $mid - (string) message-id from an email Message-Id, In-Reply-To, and + * References header. + * + * Returns: + * (array) of information containing all or some of the following keys + * 'loopback' - (bool) true or false if the message originated by + * this osTicket installation. + * 'version' - (string|FALSE) version code of the message id + * 'code' - (string) unique but predictable help desk message-id + * 'id' - (string) random characters serving as the unique id + * 'entryId' - (int) thread-entry-id from which the message originated + * 'threadId' - (int) thread-id from which the message originated + * 'staffId' - (int|null) staff the email was originally sent to + * 'userId' - (int|null) user the email was originally sent to + * 'userClass' - (string) class of user the email was sent to + * 'U' - TicketOwner + * 'S' - Staff + * 'C' - Collborator + * '?' - Something else + */ + static function decodeMessageId($mid) { + // Drop <> tokens + $mid = trim($mid, '<> '); + // Drop email domain on rhs + list($lhs, $sig) = explode('@', $mid, 2); + // LHS should be tokenized by '-' + $parts = explode('-', $lhs); + + $rv = array('loopback' => false, 'version' => false); + + // There should be at least two tokens if the message was sent by + // this system. Otherwise, there's nothing to be detected + if (count($parts) < 2) + return $rv; + + $decoders = array( + 'A' => function($id, $tag) use ($sig) { + // Old format was VA-B-C-D@sig, where C was the packed tag and D + // was blank + $format = 'Vuid/VentryId/auserClass'; + $chksig = substr(hash_hmac('sha1', $tag.$id, SECRET_SALT), -10); + if ($tag && $sig == $chksig && ($tag = base64_decode($tag))) { + // Find user and ticket id + return unpack($format, $tag); + } + return false; + }, + 'B' => function($id, $tag) { + $format = 'Vuid/VentryId/VthreadId/auserClass/a*sig'; + if ($tag && ($tag = base64_decode($tag))) { + $info = unpack($format, $tag); + $sysid = static::getSystemMessageIdCode(); + $shorttag = substr($tag, 0, 13); + $chksig = substr(hash_hmac('sha1', $shorttag.$id.$sysid, + SECRET_SALT, true), -5); + if ($chksig == $info['sig']) { + return $info; + } + } + return false; + }, + ); + + // Detect the MessageId version, which should be the first char + $rv['version'] = @$parts[0][0]; + if (!isset($decoders[$rv['version']])) + // invalid version code + return null; + + // Drop the leading version code + list($rv['code'], $rv['id'], $tag) = $parts; + $rv['code'] = substr($rv['code'], 1); + + // Verify tag signature and unpack the tag + $info = $decoders[$rv['version']]($rv['id'], $tag); + if ($info === false) + return $rv; + + $rv += $info; + + // Attempt to make the user-id more specific + $classes = array( + 'S' => 'staffId', 'U' => 'userId' + ); + if (isset($classes[$rv['userClass']])) + $rv[$classes[$rv['userClass']]] = $rv['uid']; + + // Round-trip detection - the first section is the local + // system's message-id code + $rv['loopback'] = (0 === strcasecmp($rv['code'], + static::getSystemMessageIdCode())); + + return $rv; + } + + static function getSystemMessageIdCode() { + return substr(str_replace('+', '=', + base64_encode(md5('mail'.SECRET_SALT, true))), + 0, 6); } function send($to, $subject, $message, $options=null) { @@ -97,22 +284,30 @@ class Mailer { require_once (PEAR_DIR.'Mail.php'); // PEAR Mail package require_once (PEAR_DIR.'Mail/mime.php'); // PEAR Mail_Mime packge + $messageId = $this->getMessageId($to, $options); + + if (is_object($to) && is_callable(array($to, 'getEmail'))) { + // Add personal name if available + if (is_callable(array($to, 'getName'))) { + $to = sprintf('"%s" <%s>', + $to->getName()->getOriginal(), $to->getEmail() + ); + } + else { + $to = $to->getEmail(); + } + } + //do some cleanup $to = preg_replace("/(\r\n|\r|\n)/s",'', trim($to)); $subject = preg_replace("/(\r\n|\r|\n)/s",'', trim($subject)); - /* Message ID - generated for each outgoing email */ - $messageId = sprintf('<%s-%s-%s>', - substr(md5('mail'.SECRET_SALT), -9), - Misc::randCode(9), - ($this->getEmail()?$this->getEmail()->getEmail():'@osTicketMailer')); - $headers = array ( 'From' => $this->getFromAddress(), 'To' => $to, 'Subject' => $subject, 'Date'=> date('D, d M Y H:i:s O'), - 'Message-ID' => $messageId, + 'Message-ID' => "<{$messageId}>", 'X-Mailer' =>'osTicket Mailer', ); @@ -155,6 +350,27 @@ class Mailer { } } + // Make the best effort to add In-Reply-To and References headers + if (isset($options['thread']) + && $options['thread'] instanceof ThreadEntry + ) { + if ($references = $options['thread']->getEmailReferences()) + $headers += array('References' => $references); + if ($irt = $options['thread']->getEmailMessageId()) { + // This is an response from an email, like and autoresponse. + // Web posts will not have a email message-id + $headers += array('In-Reply-To' => $irt); + } + elseif ($parent = $options['thread']->getParent()) { + // Use the parent item as the email information source. This + // will apply for staff replies + $headers += array( + 'In-Reply-To' => $parent->getEmailMessageId(), + 'References' => $parent->getEmailReferences(), + ); + } + } + // Use Mail_mime default initially $eol = null; @@ -180,17 +396,16 @@ class Mailer { // then assume that it needs html processing to create a valid text // body $isHtml = true; - $mid_token = (isset($options['thread'])) - ? $options['thread']->asMessageId($to) : ''; if (!(isset($options['text']) && $options['text'])) { $tag = ''; if ($cfg && $cfg->stripQuotedReply() && (!isset($options['reply-tag']) || $options['reply-tag'])) - $tag = $cfg->getReplySeparator() . '<br/><br/>'; - $message = "<div style=\"display:none\" - data-mid=\"$mid_token\">$tag</div>$message"; + $tag = '<div>'.$cfg->getReplySeparator() . '<br/><br/></div>'; + // Embed the data-mid in such a way that it should be included + // in a response + $message = "<div data-mid=\"$messageId\">{$tag}{$message}</div>"; $txtbody = rtrim(Format::html2text($message, 90, false)) - . ($mid_token ? "\nRef-Mid: $mid_token\n" : ''); + . ($messageId ? "\nRef-Mid: $messageId\n" : ''); $mime->setTXTBody($txtbody); } else { @@ -211,7 +426,14 @@ class Mailer { $self = $this; $message = preg_replace_callback('/cid:([\w.-]{32})/', function($match) use ($domain, $mime, $self) { - if (!($file = AttachmentFile::lookup($match[1]))) + $file = false; + foreach ($self->attachments as $id=>$F) { + if (strcasecmp($F->getKey(), $match[1]) === 0) { + $file = $F; + break; + } + } + if (!$file) return $match[0]; $mime->addHTMLImage($file->getData(), $file->getType(), $file->getName(), false, @@ -225,12 +447,9 @@ class Mailer { } //XXX: Attachments if(($attachments=$this->getAttachments())) { - foreach($attachments as $attachment) { - if ($attachment['file_id'] - && ($file=AttachmentFile::lookup($attachment['file_id']))) { - $mime->addAttachment($file->getData(), - $file->getType(), $file->getName(),false); - } + foreach($attachments as $id=>$file) { + $mime->addAttachment($file->getData(), + $file->getType(), $file->getName(),false); } } diff --git a/include/class.mailfetch.php b/include/class.mailfetch.php index 996192e9bca1798d64c610d2457b71581e680b18..4e0e3c43059aac9c100ea390ffd3622551d5aba6 100644 --- a/include/class.mailfetch.php +++ b/include/class.mailfetch.php @@ -721,23 +721,31 @@ class MailFetcher { Signal::send('mail.processed', $this, $vars); $seen = false; - if (($thread = ThreadEntry::lookupByEmailHeaders($vars, $seen)) - && ($t=$thread->getTicket()) - && ($vars['staffId'] - || !$t->isClosed() - || $t->isReopenable()) - && ($message = $thread->postEmail($vars))) { + if (($entry = ThreadEntry::lookupByEmailHeaders($vars, $seen)) + && ($message = $entry->postEmail($vars)) + ) { if (!$message instanceof ThreadEntry) // Email has been processed previously return $message; - $ticket = $message->getTicket(); - } elseif ($seen) { + // NOTE: This might not be a "ticket" + $ticket = $message->getThread()->getObject(); + } + elseif ($seen) { // Already processed, but for some reason (like rejection), no // thread item was created. Ignore the email return true; - } elseif (($ticket=Ticket::create($vars, $errors, 'Email'))) { + } + // Allow continuation of thread without initial message or note + elseif (($thread = Thread::lookupByEmailHeaders($vars)) + && ($message = $entry->postEmail($vars)) + ) { + // NOTE: This might not be a "ticket" + $ticket = $thread->getObject(); + } + elseif (($ticket=Ticket::create($vars, $errors, 'Email'))) { $message = $ticket->getLastMessage(); - } else { + } + else { //Report success if the email was absolutely rejected. if(isset($errors['errno']) && $errors['errno'] == 403) { // Never process this email again! diff --git a/include/class.organization.php b/include/class.organization.php index fbaa19df50dd8328b900e2c235421a3148862c15..05b23ca1230eb089dc285f8344116a454f1ca0ac 100644 --- a/include/class.organization.php +++ b/include/class.organization.php @@ -370,6 +370,7 @@ class Organization extends OrganizationModel { $org->addDynamicData($vars); } + Signal::send('organization.created', $user); return $org; } diff --git a/include/class.orm.php b/include/class.orm.php index 17dd050357a5e1b55d24083e8cdea1a5aad59606..26ea31529dea4bb51e68094bee960dc2230cf6f4 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -256,8 +256,8 @@ class VerySimpleModel { // replaced in the dirty array if (!array_key_exists($field, $this->dirty)) $this->dirty[$field] = $old; - $this->ht[$field] = $value; } + $this->ht[$field] = $value; } function __set($field, $value) { return $this->set($field, $value); @@ -2102,6 +2102,10 @@ class MysqlExecutor { $types .= 'd'; elseif (is_string($p)) $types .= 's'; + elseif ($p instanceof DateTime) { + $types .= 's'; + $p = $p->format('Y-m-d h:i:s'); + } // TODO: Emit error if param is null $ps[] = &$p; } diff --git a/include/class.search.php b/include/class.search.php index 538aa7fddf51f48650c9a8ed114c28a4ddef3b23..5436ff519333b7ff3e578703fc13672cd194ed34 100644 --- a/include/class.search.php +++ b/include/class.search.php @@ -197,7 +197,12 @@ class SearchInterface { // Tickets, which can be edited as well // Knowledgebase articles (FAQ and canned responses) // Users, organizations - Signal::connect('model.created', array($this, 'createModel')); + Signal::connect('threadentry.created', array($this, 'createModel')); + Signal::connect('ticket.created', array($this, 'createModel')); + Signal::connect('user.created', array($this, 'createModel')); + Signal::connect('organization.created', array($this, 'createModel')); + Signal::connect('model.created', array($this, 'createModel'), 'FAQ'); + Signal::connect('model.updated', array($this, 'updateModel')); #Signal::connect('model.deleted', array($this, 'deleteModel')); } diff --git a/include/class.staff.php b/include/class.staff.php index 3201db170e8e1364d992e1d753ac2932dc1103eb..87bb3e073c77c3c39b687cc6452459ff128afe98 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -24,7 +24,7 @@ include_once(INCLUDE_DIR.'class.user.php'); include_once(INCLUDE_DIR.'class.auth.php'); class Staff extends VerySimpleModel -implements AuthenticatedUser { +implements AuthenticatedUser, EmailContact { static $meta = array( 'table' => STAFF_TABLE, @@ -640,7 +640,7 @@ implements AuthenticatedUser { return $row ? $row[0] : 0; } - function getIdByEmail($email) { + static function getIdByEmail($email) { $row = static::objects()->filter(array('email' => $email)) ->values_flat('staff_id')->first(); return $row ? $row[0] : 0; diff --git a/include/class.task.php b/include/class.task.php index 54da8170f882a43bc4d4935835be8ee505f051b6..610c0271fb39452354fe4eb5e2348fbcd6f82fe0 100644 --- a/include/class.task.php +++ b/include/class.task.php @@ -225,9 +225,11 @@ class Task extends TaskModel { return $this->getThread()->getEntry($id); } - function getThreadEntries($type, $order='') { - return $this->getThread()->getEntries( - array('type' => $type, 'order' => $order)); + function getThreadEntries($type=false) { + $thread = $this->getThread()->getEntries(); + if ($type && is_array($type)) + $thread->filter(array('type__in' => $type)); + return $thread; } function getForm() { @@ -437,7 +439,7 @@ class Task extends TaskModel { // Create a thread + message. $thread = TaskThread::create($task); $thread->addDescription($vars); - Signal::send('model.created', $task); + Signal::send('task.created', $task); return $task; } @@ -574,10 +576,12 @@ class TaskThread extends ObjectThread { static function create($task) { $id = is_object($task) ? $task->getId() : $task; - return parent::create(array( + $thread = parent::create(array( 'object_id' => $id, 'object_type' => ObjectModel::OBJECT_TYPE_TASK )); + if ($thread->save()) + return $thread; } } diff --git a/include/class.thread.php b/include/class.thread.php index d3abcf411210dc6154f38d09a8a68a530838f71d..0dba7135cf4c7b455334a91e3f3f1c3ef568ae7f 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -17,7 +17,8 @@ include_once(INCLUDE_DIR.'class.ticket.php'); include_once(INCLUDE_DIR.'class.draft.php'); -class ThreadModel extends VerySimpleModel { +//Ticket thread. +class Thread extends VerySimpleModel { static $meta = array( 'table' => THREAD_TABLE, 'pk' => array('id'), @@ -29,66 +30,23 @@ class ThreadModel extends VerySimpleModel { ), ), 'entries' => array( - 'reverse' => 'ThreadEntryModel.thread', + 'reverse' => 'ThreadEntry.thread', ), ), ); -} - -//Ticket thread. -class Thread { - - var $ht; - - function Thread($criteria) { - $this->load($criteria); - } - - function load($criteria=null) { - - if (!$criteria && !($criteria=$this->getId())) - return null; - - $sql='SELECT thread.* ' - .' ,count(DISTINCT a.id) as attachments ' - .' ,count(DISTINCT entry.id) as entries ' - .' FROM '.THREAD_TABLE.' thread ' - .' LEFT JOIN '.THREAD_ENTRY_TABLE.' entry - ON (entry.thread_id = thread.id) ' - .' LEFT JOIN '.ATTACHMENT_TABLE.' a - 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); - - return ($this->ht); - } - - function reload() { - return $this->load(); - } + var $_object; function getId() { - return $this->ht['id']; + return $this->id; } function getObjectId() { - return $this->ht['object_id']; + return $this->object_id; } function getObjectType() { - return $this->ht['object_type']; + return $this->object_type; } function getObject() { @@ -101,190 +59,351 @@ class Thread { } function getNumAttachments() { - return $this->ht['attachments']; + return Attachment::objects()->filter(array( + 'thread_entry__thread' => $this + ))->count(); } function getNumEntries() { - return $this->ht['entries']; - } - - function getEntries($criteria) { - - if (!$criteria['order'] || !in_array($criteria['order'], array('DESC','ASC'))) - $criteria['order'] = 'ASC'; - - $sql='SELECT entry.* - , 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 ' - .' LEFT JOIN '.USER_TABLE.' user - ON (entry.user_id=user.id) ' - .' LEFT JOIN '.STAFF_TABLE.' staff - ON (entry.staff_id=staff.staff_id) ' - .' 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']); - - $sql.=' GROUP BY entry.id ' - .' ORDER BY entry.created '.$criteria['order']; - - if ($criteria['limit']) - $sql.=' LIMIT '.$criteria['limit']; - - $entries = array(); - if(($res=db_query($sql)) && db_num_rows($res)) { - while($rec=db_fetch_array($res)) { - $rec['body'] = ThreadEntryBody::fromFormattedText($rec['body'], $rec['format']); - $entries[] = $rec; - } - } + return $this->entries->count(); + } - return $entries; + function getEntries($criteria=false) { + $base = $this->entries->annotate(array( + 'has_attachments' => SqlAggregate::COUNT('attachments') + )); + if ($criteria) + $base->filter($criteria); + return $base; } function getEntry($id) { return ThreadEntry::lookup($id, $this->getId()); } + /** + * 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 | + // +------------------+-------------------+-------------+ - function deleteAttachments() { + if (!$object = $this->getObject()) { + // How should someone find this thread? + return false; + } + elseif ($object instanceof Ticket && ( + !$mailinfo['staffId'] + && $object->isClosed() + && !$object->isReopenable() + )) { + // Ticket is closed, not reopenable, and email was not submitted + // by an agent. Email cannot be submitted + return false; + } + + // 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); + return true; + } + + $vars = array( + 'mid' => $mailinfo['mid'], + 'header' => $mailinfo['header'], + 'poster' => $mailinfo['name'], + 'origin' => 'Email', + 'source' => 'Email', + 'ip' => '', + 'reply_to' => $this, + 'recipients' => $mailinfo['recipients'], + 'to-email-id' => $mailinfo['to-email-id'], + ); + + // XXX: Is this necessary? + if ($object instanceof Ticket) + $vars['ticketId'] = $object->getId(); + if ($object instanceof Task) + $vars['taskId'] = $object->getId(); + + $errors = array(); + + if (isset($mailinfo['attachments'])) + $vars['attachments'] = $mailinfo['attachments']; + + $body = $mailinfo['message']; + $poster = $mailinfo['email']; + + // 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'] || ( + $object instanceof Ticket + && strcasecmp($mailinfo['email'], $object->getEmail()) == 0 + )) { + $vars['message'] = $body; + $vars['userId'] = $mailinfo['userId'] ?: $object->getUserId(); + $vars['origin'] = 'Email'; + + if ($object instanceof Threadable) + return $object->postThreadEntry('M', $vars); + elseif ($this instanceof ObjectThread) + $this->addMessage($vars, $errors); + else + throw new Exception('Cannot continue discussion with abstract thread'); + } + // XXX: Consider collaborator role + elseif ($mailinfo['staffId'] + || ($mailinfo['staffId'] = Staff::getIdByEmail($mailinfo['email']))) { + $vars['staffId'] = $mailinfo['staffId']; + $vars['poster'] = Staff::lookup($mailinfo['staffId']); + $vars['note'] = $body; + + if ($object instanceof Threadable) + return $object->postThreadEntry('N', $vars); + elseif ($this instanceof ObjectThread) + return $this->addNote($vars, $errors); + else + throw new Exception('Cannot continue discussion with abstract thread'); + } + 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; + $vars['poster'] = $poster; + if ($object instanceof Threadable) + return $object->postThreadEntry('N', $vars); + elseif ($this instanceof ObjectThread) + return $this->addNote($vars, $errors); + else + throw new Exception('Cannot continue discussion with abstract thread'); + } + } + // TODO: Consider security constraints + else { + //XXX: Are we potentially leaking the email address to + // collaborators? + // Try not to destroy the format of the body + $body->prepend(sprintf('Received From: %s', $mailinfo['email'])); + $vars['message'] = $body; + $vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner? + $vars['origin'] = 'Email'; + if ($object instanceof Threadable) + return $object->postThreadEntry('M', $vars); + elseif ($this instanceof ObjectThread) + return $this->addMessage($vars, $errors); + else + throw new Exception('Cannot continue discussion with abstract thread'); + } + // Currently impossible, but indicate that this thread object could + // not append the incoming email. + return false; + } - // 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()); + function deleteAttachments() { + $deleted = Attachment::objects()->filter(array( + 'thread_entry__thread' => $this, + ))->delete(); - $deleted=0; - if (($res=db_query($sql)) && ($deleted=db_affected_rows())) + if ($deleted) AttachmentFile::deleteOrphans(); return $deleted; } + /** + * Function: lookupByEmailHeaders + * + * Attempt to locate a thread by the email headers. It should be + * considered a secondary lookup to ThreadEntry::lookupByEmailHeaders(), + * which should find an actual thread entry, which should be possible + * for all email communcation which is associated with a thread entry. + * The only time where this is useful is for threads which triggered + * email communication without a thread entry, for instance, like + * tickets created without an initial message. + */ + function lookupByEmailHeaders(&$mailinfo) { + $possibles = array(); + 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 + $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]; + } + + foreach ($possibles as $mid) { + // Attempt to detect the ticket and user ids from the + // message-id header. If the message originated from + // osTicket, the Mailer class can break it apart. If it came + // from this help desk, the 'loopback' property will be set + // to true. + $mid_info = Mailer::decodeMessageId($mid); + if ($mid_info['loopback'] && isset($mid_info['uid']) + && @$mid_info['threadId'] + && ($t = Thread::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; + } + } + + return null; + } + + function delete() { //Self delete - $sql = 'DELETE FROM '.THREAD_TABLE.' WHERE - id='.db_input($this->getId()); - - if (!db_query($sql) || !db_affected_rows()) + if (!parent::delete()) return false; // 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); + ThreadEntryEmailInfo::objects() + ->filter(array('thread_entry__thread' => $this)) + ->update(array('headers' => null)); // Mass delete entries $this->deleteAttachments(); - $sql = 'DELETE FROM '.THREAD_ENTRY_TABLE - . ' WHERE thread_id='.db_input($this->getId()); - db_query($sql); + + $this->entries->delete(); return true; } 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; + $inst = parent::create($vars); + $inst->created = SqlFunction::NOW(); + return $inst; } +} - static function lookup($id) { - - return ($id - && ($thread = new Thread($id)) - && $thread->getId() - ) - ? $thread : null; - } +class ThreadEntryEmailInfo extends VerySimpleModel { + static $meta = array( + 'table' => THREAD_ENTRY_EMAIL_TABLE, + 'pk' => array('id'), + 'joins' => array( + 'thread_entry' => array( + 'constraint' => array('thread_entry_id' => 'ThreadEntry.id'), + ), + ), + ); } -class ThreadEntryModel extends VerySimpleModel { +class ThreadEntry extends VerySimpleModel { static $meta = array( 'table' => THREAD_ENTRY_TABLE, 'pk' => array('id'), + 'select_related' => array('staff', 'user', 'email_info'), 'joins' => array( 'thread' => array( - 'constraint' => array('thread_id' => 'ThreadModel.id'), + 'constraint' => array('thread_id' => 'Thread.id'), + ), + 'parent' => array( + 'constraint' => array('pid' => 'ThreadEntry.id'), + 'null' => true, + ), + 'children' => array( + 'reverse' => 'ThreadEntry.parent', + ), + 'email_info' => array( + 'reverse' => 'ThreadEntryEmailInfo.thread_entry', + 'list' => false, ), 'attachments' => array( - 'reverse' => 'AttachmentModel.thread', + 'reverse' => 'Attachment.thread_entry', + 'null' => true, + ), + 'staff' => array( + 'constraint' => array('staff_id' => 'Staff.staff_id'), + 'null' => true, + ), + 'user' => array( + 'constraint' => array('user_id' => 'User.id'), 'null' => true, ), ), ); -} - -class ThreadEntry { - - var $id; - var $ht; - var $thread; - var $attachments; + var $_headers; + var $_thread; var $_actions; + var $_attachments; - function ThreadEntry($id, $threadId=0, $type='') { - $this->load($id, $threadId, $type); - } - - function load($id=0, $threadId=0, $type='') { - - if (!$id && !($id=$this->getId())) - 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") ' - .' WHERE entry.id='.db_input($id); - - if ($type) - $sql.=' AND entry.type='.db_input($type); - - if ($threadId) - $sql.=' AND entry.thread_id='.db_input($threadId); - - $sql.=' GROUP BY entry.id '; - - if (!($res=db_query($sql)) || !db_num_rows($res)) + function postEmail($mailinfo) { + if (!($thread = $this->getThread())) + // Kind of hard to continue a discussion without a thread ... return false; - $this->ht = db_fetch_array($res); - $this->id = $this->ht['id']; - $this->attachments = new GenericAttachments($this->id, 'H'); - - return true; - } + elseif ($this->getEmailMessageId() == $mailinfo['mid']) + // Reporting success so the email can be moved or deleted. + return true; - function reload() { - return $this->load(); + return $thread->postEmail($mailinfo); } function getId() { @@ -292,27 +411,32 @@ class ThreadEntry { } function getPid() { - return $this->ht['pid']; + return $this->pid; + } + + function getParent() { + if ($this->getPid()) + return ThreadEntry::lookup($this->getPid()); } function getType() { - return $this->ht['type']; + return $this->type; } function getSource() { - return $this->ht['source']; + return $this->source; } function getPoster() { - return $this->ht['poster']; + return $this->poster; } function getTitle() { - return $this->ht['title']; + return $this->title; } function getBody() { - return ThreadEntryBody::fromFormattedText($this->ht['body'], $this->ht['format']); + return ThreadEntryBody::fromFormattedText($this->body, $this->format); } function setBody($body) { @@ -325,36 +449,37 @@ class ThreadEntry { $body = new TextThreadEntryBody($body); } - $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(); + $this->format = $body->getType(); + $this->body = (string) $body; + return $this->save(); } function getCreateDate() { - return $this->ht['created']; + return $this->created; } function getUpdateDate() { - return $this->ht['updated']; + return $this->updated; } function getNumAttachments() { - return $this->ht['attachments']; + return $this->attachments->count(); } function getEmailMessageId() { - return $this->ht['mid']; + if ($this->email_info) + return $this->email_info->mid; } 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']; + if (!isset($this->_headers) && $this->email_info + && isset($this->email_info->headers) + ) { + $this->_headers = Mail_Parse::splitHeaders($this->email_info->headers); + } + return $this->_headers; } function getEmailReferences($include_mid=true) { @@ -362,32 +487,11 @@ class ThreadEntry { $headers = self::getEmailHeaderArray(); if (isset($headers['References']) && $headers['References']) $references = $headers['References']." "; - if ($include_mid) - $references .= $this->getEmailMessageId(); + if ($include_mid && ($mid = $this->getEmailMessageId())) + $references .= $mid; 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', @@ -399,43 +503,46 @@ class ThreadEntry { } function getThreadId() { - return $this->ht['thread_id']; + return $this->thread_id; } function getThread() { - if(!$this->thread && $this->getThreadId()) - $this->thread = Thread::lookup($this->getThreadId()); + if (!isset($this->_thread) && $this->thread_id) + // TODO: Consider typing the thread based on its type field + $this->_thread = ObjectThread::lookup($this->getThreadId()); - return $this->thread; + return $this->_thread; } function getStaffId() { - return $this->ht['staff_id']; + return isset($this->staff_id) ? $this->staff_id : 0; } function getStaff() { - - if(!$this->staff && $this->getStaffId()) - $this->staff = Staff::lookup($this->getStaffId()); - return $this->staff; } function getUserId() { - return $this->ht['user_id']; + return isset($this->user_id) ? $this->user_id : 0; } function getUser() { + return $this->user; + } - if (!isset($this->user)) - $this->user = User::lookup($this->getUserId()); + function getName() { + if ($this->staff_id) + return $this->staff->getName(); + if ($this->user_id) + return $this->user->getName(); - return $this->user; + return $this->poster; } function getEmailHeader() { - return $this->ht['headers']; + if ($this->email_info) + return $this->email_info->headers; } function isAutoReply() { @@ -472,8 +579,8 @@ class ThreadEntry { continue; if(!$file['error'] - && ($id=AttachmentFile::upload($file)) - && $this->saveAttachment($id)) + && ($F=AttachmentFile::upload($file)) + && $this->saveAttachment($F)) $uploaded[]=$id; else { if(!$file['error']) @@ -514,8 +621,8 @@ class ThreadEntry { if(!$attachment || !is_array($attachment)) return null; - $id=0; - if ($attachment['error'] || !($id=$this->saveAttachment($attachment))) { + $A=null; + if ($attachment['error'] || !($A=$this->saveAttachment($attachment))) { $error = $attachment['error']; if(!$error) $error = sprintf(_S('Unable to import attachment - %s'), @@ -525,7 +632,7 @@ class ThreadEntry { _S('File Import Error'), $error, _S('SYSTEM'), false); } - return $id; + return $A; } /* @@ -536,28 +643,47 @@ class ThreadEntry { $inline = is_array($file) && @$file['inline']; - return $this->attachments->save($file, $inline); + if (is_numeric($file)) + $fileId = $file; + elseif ($file instanceof AttachmentFile) + $fileId = $file->getId(); + elseif ($F = AttachmentFile::create($file)) + $fileId = $F->getId(); + elseif (is_array($file) && isset($file['id'])) + $fileId = $file['id']; + else + return false; + + $att = Attachment::create(array( + 'type' => 'H', + 'object_id' => $this->getId(), + 'file_id' => $fileId, + 'inline' => $inline ? 1 : 0, + )); + if (!$att->save()) + return false; + return $att; } function saveAttachments($files) { - $ids=array(); + $attachments = array(); foreach ($files as $file) - if (($id=$this->saveAttachment($file))) - $ids[] = $id; + if (($A = $this->saveAttachment($file))) + $attachments[] = $A; - return $ids; + return $attachments; } function getAttachments() { - return $this->attachments->getAll(false); + return $this->attachments; } function getAttachmentUrls() { $json = array(); - foreach ($this->getAttachments() as $att) { - $json[$att['key']] = array( - 'download_url' => $att['download_url'], - 'filename' => $att['name'], + foreach ($this->attachments as $att) { + $json[$att->file->getKey()] = array( + 'download_url' => $att->file->getDownloadUrl(), + 'filename' => $att->file->name, ); } @@ -565,146 +691,30 @@ class ThreadEntry { } function getAttachmentsLinks($file='attachment.php', $target='_blank', $separator=' ') { + // TODO: Move this to the respective UI templates $str=''; - foreach ($this->getAttachments() as $att ) { - if ($att['inline']) continue; + foreach ($this->attachments as $att ) { + if ($att->inline) continue; $size = ''; - if ($att['size']) - $size=sprintf('<em>(%s)</em>', Format::file_size($att['size'])); + if ($att->file->size) + $size=sprintf('<em>(%s)</em>', Format::file_size($att->file->size)); - $str.=sprintf('<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%s %s', - $att['download_url'], $target, Format::htmlchars($att['name']), $size, $separator); + $str .= sprintf( + '<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%s %s', + $att->file->getDownloadUrl(), $target, + Format::htmlchars($att->file->name), $size, $separator); } return $str; } - /** - * 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 - @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); - 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'] ? $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']; + foreach($this->attachments as $attachment) + $files[$attachment->file_id] = $attachment->file->name; return $files; } @@ -733,13 +743,15 @@ class ThreadEntry { if (!$id || !$mid) return false; - $sql='INSERT INTO '.THREAD_ENTRY_EMAIL_TABLE - .' SET thread_entry_id='.db_input($id) - .', mid='.db_input($mid); + $this->email_info = ThreadEntryEmailInfo::create(array( + 'thread_entry_id' => $id, + 'mid' => $mid, + )); + if ($header) - $sql .= ', headers='.db_input($header); + $this->email_info->headers = trim($header); - return db_query($sql) ? db_insert_id() : 0; + return $this->email_info->save(); } /* variables */ @@ -769,14 +781,6 @@ class ThreadEntry { return false; } - static function lookup($id, $tid=0, $type='') { - return ($id - && is_numeric($id) - && ($e = new ThreadEntry($id, $tid, $type)) - && $e->getId()==$id - )?$e:null; - } - /** * Parameters: * mailinfo (hash<String>) email header information. Must include keys @@ -793,16 +797,15 @@ class ThreadEntry { function lookupByEmailHeaders(&$mailinfo, &$seen=false) { // Search for messages using the References header, then the // in-reply-to header - $search = 'SELECT thread_entry_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']))))) { + if ($entry = ThreadEntry::objects() + ->filter(array('email_info__mid' => $mailinfo['mid'])) + ->first() + ) { $seen = true; - return ThreadEntry::lookup($id); + return $entry; } + $possibles = array(); foreach (array('in-reply-to', 'references') as $header) { $matches = array(); if (!isset($mailinfo[$header]) || !$mailinfo[$header]) @@ -817,41 +820,71 @@ class ThreadEntry { // (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"; + $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) { + //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"; + } + $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; } - $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['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; } - // 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; } + // 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 @@ -880,6 +913,8 @@ class ThreadEntry { } // 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('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`', $mailinfo['message'], $match)) if ($thread = ThreadEntry::lookupByRefMessageId($match[1], @@ -891,7 +926,9 @@ class ThreadEntry { /** * Find a thread entry from a message-id created from the - * ::asMessageId() method + * ::asMessageId() method. + * + * *DEPRECATED* use Mailer::decodeMessageId() instead */ function lookupByRefMessageId($mid, $from) { $mid = trim($mid, '<>'); @@ -909,36 +946,7 @@ class ThreadEntry { 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->getThread()->getObject(); - return sprintf('$%s$%s@%s', - base64_encode(pack('V', $this->getId())), - substr(md5($to . $ticket->getNumber() . $ticket->getId()), -10), - substr($domain, -10) - ); + return $thread; } //new entry ... we're trusting the caller to check validity of the data. @@ -987,36 +995,36 @@ class ThreadEntry { if ($poster && is_object($poster)) $poster = (string) $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) - .' ,`source` = '.db_input($vars['source']); + $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) - $sql.=' ,body='.db_input($body); + $entry->body = $body; if (isset($vars['pid'])) - $sql.=' ,pid='.db_input($vars['pid']); + $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) - $sql.=' ,pid='.db_input($vars['reply_to']->getId()); + $entry->pid = $vars['reply_to']->getId(); if ($vars['ip_address']) - $sql.=' ,ip_address='.db_input($vars['ip_address']); + $entry->ip_address = $vars['ip_address']; - //echo $sql; - if (!db_query($sql) - || !($entry=self::lookup(db_insert_id(), $vars['threadId']))) + if (!$entry->save()) return false; /************* ATTACHMENTS *****************/ @@ -1050,31 +1058,23 @@ class ThreadEntry { } } - $sql = 'UPDATE '.THREAD_ENTRY_TABLE - .' SET body='.db_input($body) - .' WHERE `id`='.db_input($entry->getId()); - - if (!db_query($sql) || !db_affected_rows()) + $entry->body = $body; + if (!$entry->save()) return false; } - // Email message id (required for all thread posts) - if (!isset($vars['mid'])) - $vars['mid'] = sprintf('<%s@%s>', - Misc::randCode(24), substr(md5($cfg->getUrl()), -10)); - + // Save mail message id, if available $entry->saveEmailInfo($vars); // Inline images (attached to the draft) $entry->saveAttachments(Draft::getAttachmentIds($body)); - Signal::send('model.created', $entry); - + Signal::send('threadentry.created', $this); return $entry; } static function add($vars) { - return ($entry=self::create($vars)) ? $entry->getId() : 0; + return self::create($vars); } // Extensible thread entry actions ------------------------ @@ -1266,6 +1266,10 @@ class TextThreadEntryBody extends ThreadEntryBody { return Format::stripEmptyLines($this->body); } + function prepend($what) { + $this->body = $what . "\n\n" . $this->body; + } + function display($output=false) { if ($this->isEmpty()) return '(empty)'; @@ -1313,6 +1317,10 @@ class HtmlThreadEntryBody extends ThreadEntryBody { 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)'; @@ -1334,16 +1342,12 @@ class MessageThreadEntry extends ThreadEntry { const ENTRY_TYPE = 'M'; - function MessageThreadEntry($id, $threadId=0) { - parent::ThreadEntry($id, $threadId, self::ENTRY_TYPE); - } - function getSubject() { return $this->getTitle(); } static function create($vars, &$errors) { - return self::lookup(self::add($vars, $errors)); + return static::add($vars, $errors); } static function add($vars, &$errors) { @@ -1365,16 +1369,6 @@ class MessageThreadEntry extends ThreadEntry { return parent::add($vars); } - - static function lookup($id, $tid=0) { - - return ($id - && is_numeric($id) - && ($m = new MessageThreadEntry($id, $tid)) - && $m->getId()==$id - )?$m:null; - } - } /* thread entry of type response */ @@ -1382,10 +1376,6 @@ class ResponseThreadEntry extends ThreadEntry { const ENTRY_TYPE = 'R'; - function ResponseThreadEntry($id, $threadId=0) { - parent::ThreadEntry($id, $threadId, self::ENTRY_TYPE); - } - function getSubject() { return $this->getTitle(); } @@ -1395,7 +1385,7 @@ class ResponseThreadEntry extends ThreadEntry { } static function create($vars, &$errors) { - return self::lookup(self::add($vars, $errors)); + return static::add($vars, $errors); } static function add($vars, &$errors) { @@ -1419,31 +1409,18 @@ class ResponseThreadEntry extends ThreadEntry { return parent::add($vars); } - - static function lookup($id, $tid=0) { - - return ($id - && is_numeric($id) - && ($r = new ResponseThreadEntry($id, $tid)) - && $r->getId()==$id - )?$r:null; - } } /* Thread entry of type note (Internal Note) */ class NoteThreadEntry extends ThreadEntry { const ENTRY_TYPE = 'N'; - function NoteThreadEntry($id, $threadId=0) { - parent::ThreadEntry($id, $threadId, self::ENTRY_TYPE); - } - function getMessage() { return $this->getBody(); } static function create($vars, &$errors) { - return self::lookup(self::add($vars, $errors)); + return self::add($vars, $errors); } static function add($vars, &$errors) { @@ -1462,68 +1439,66 @@ class NoteThreadEntry extends ThreadEntry { return parent::add($vars); } - - static function lookup($id, $tid=0) { - - return ($id - && is_numeric($id) - && ($n = new NoteThreadEntry($id, $tid)) - && $n->getId()==$id - )?$n:null; - } } // Object specific thread utils. class ObjectThread extends Thread { private $_entries = array(); - function __construct($id) { + static $types = array( + ObjectModel::OBJECT_TYPE_TASK => 'TaskThread', + ); + + var $counts; - parent::__construct($id); + function getCounts() { + if (!isset($this->counts) && $this->getId()) { + $this->counts = array(); - if ($this->getId()) { - $sql= ' SELECT `type`, count(DISTINCT e.id) as count ' - .' FROM '.THREAD_TABLE. ' t ' - .' INNER JOIN '.THREAD_ENTRY_TABLE. ' e ON (e.thread_id = t.id) ' - .' WHERE t.id='.db_input($this->getId()) - .' GROUP BY e.`type`'; + $stuff = static::objects()->annotate(array( + 'count' => SqlAggregate::COUNT('thread_entry', true) + )) + ->values_flat('thread_entry__type', 'count'); + print $stuff; - if (($res=db_query($sql)) && db_num_rows($res)) { - while ($row=db_fetch_row($res)) - $this->_entries[$row[0]] = $row[1]; + foreach ($stuff as $row) { + list($type, $count) = $row; + $this->counts[$type] = $count; } } } function getNumMessages() { - return $this->_entries[MessageThreadEntry::ENTRY_TYPE]; + $this->getCounts(); + return $this->counts[MessageThreadEntry::ENTRY_TYPE]; } function getNumResponses() { - return $this->_entries[ResponseThreadEntry::ENTRY_TYPE]; + $this->getCounts(); + return $this->counts[ResponseThreadEntry::ENTRY_TYPE]; } function getNumNotes() { - return $this->_entries[NoteThreadEntry::ENTRY_TYPE]; + $this->getCounts(); + return $this->counts[NoteThreadEntry::ENTRY_TYPE]; } function getMessages() { - return $this->getEntries(array( - 'type' => MessageThreadEntry::ENTRY_TYPE)); + return $this->entries->filter(array( + 'type' => MessageThreadEntry::ENTRY_TYPE + )); } function getLastMessage() { - - $criteria = array( - 'type' => MessageThreadEntry::ENTRY_TYPE, - 'order' => 'DESC', - 'limit' => 1); - - return $this->getEntry($criteria); + 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 { @@ -1537,13 +1512,15 @@ class ObjectThread extends Thread { } function getResponses() { - return $this->getEntries(array( - 'type' => ResponseThreadEntry::ENTRY_TYPE)); + return $this->entries->filter(array( + 'type' => ResponseThreadEntry::ENTRY_TYPE + )); } function getNotes() { - return $this->getEntries(array( - 'type' => NoteThreadEntry::ENTRY_TYPE)); + return $this->entries->filter(array( + 'type' => NoteThreadEntry::ENTRY_TYPE + )); } function addNote($vars, &$errors) { @@ -1572,33 +1549,36 @@ class ObjectThread extends Thread { function getVar($name) { switch ($name) { case 'original': - $entries = $this->getEntries(array( - 'type' => MessageThreadEntry::ENTRY_TYPE, - 'order' => 'ASC', - 'limit' => 1)); - if ($entries && $entries[0]) - return (string) $entries[0]['body']; + $entry = $this->entries->filter(array( + 'type' => MessageThreadEntry::ENTRY_TYPE, + )) + ->order_by('id') + ->first(); + if ($entry) + return $entry->getBody(); break; case 'last_message': case 'lastmessage': - $entries = $this->getEntries(array( - 'type' => MessageThreadEntry::ENTRY_TYPE, - 'order' => 'DESC', - 'limit' => 1)); - if ($entries && $entries[0]) - return (string) $entries[0]['body']; + $entry = $this->getLastMessage(); + if ($entry) + return $entry->getBody(); break; } } - static function lookup($criteria) { + static function lookup($criteria, $type=false) { + if (!$type) + return parent::lookup($criteria); + + $class = false; + if (isset(self::$types[$type])) + $class = self::$types[$type]; + if (!class_exists($class)) + $class = get_called_class(); - return ($criteria - && ($t= new static($criteria)) - && $t->getId() - ) ? $t : null; + return $class::lookup($criteria); } } @@ -1607,10 +1587,12 @@ class TicketThread extends ObjectThread { static function create($ticket) { $id = is_object($ticket) ? $ticket->getId() : $ticket; - return parent::create(array( + $thread = parent::create(array( 'object_id' => $id, 'object_type' => ObjectModel::OBJECT_TYPE_TICKET )); + if ($thread->save()) + return $thread; } } @@ -1689,4 +1671,8 @@ abstract class ThreadEntryAction { ); } } + +interface Threadable { + function postThreadEntry($type, $vars); +} ?> diff --git a/include/class.ticket.php b/include/class.ticket.php index 0b3c6574107193faf282fccb2fe34a0b32ab026b..a7a91190af8d205e386d644f2624a84359fe574f 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -75,7 +75,7 @@ class TicketModel extends VerySimpleModel { 'null' => true, ), 'thread' => array( - 'reverse' => 'ThreadModel.ticket', + 'reverse' => 'Thread.ticket', 'list' => false, 'null' => true, ), @@ -217,7 +217,7 @@ TicketCData::$meta['table'] = TABLE_PREFIX . 'ticket__cdata'; class Ticket -implements RestrictedAccess { +implements RestrictedAccess, Threadable { var $id; var $number; @@ -818,7 +818,7 @@ implements RestrictedAccess { } function getThreadCount() { - return $this->getNumMessages() + $this->getNumResponses(); + return $this->getClientThread()->count(); } function getNumMessages() { @@ -834,15 +834,15 @@ implements RestrictedAccess { } function getMessages() { - return $this->getThreadEntries('M'); + return $this->getThreadEntries(array('M')); } function getResponses() { - return $this->getThreadEntries('R'); + return $this->getThreadEntries(array('R')); } function getNotes() { - return $this->getThreadEntries('N'); + return $this->getThreadEntries(array('N')); } function getClientThread() { @@ -853,9 +853,11 @@ implements RestrictedAccess { return $this->getThread()->getEntry($id); } - function getThreadEntries($type, $order='') { - return $this->getThread()->getEntries( - array( 'type' => $type, 'order' => $order)); + function getThreadEntries($type=false) { + $thread = $this->getThread()->getEntries(); + if ($type && is_array($type)) + $thread->filter(array('type__in' => $type)); + return $thread; } //Collaborators @@ -1271,6 +1273,11 @@ implements RestrictedAccess { 'thread'=>$message ); } + else { + $options += array( + 'thread' => $this->getThread(), + ); + } //Send auto response - if enabled. if($autorespond @@ -1284,7 +1291,7 @@ implements RestrictedAccess { 'signature' => ($dept && $dept->isPublic())?$dept->getSignature():'') ); - $email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body'], + $email->sendAutoReply($this->getOwner(), $msg['subj'], $msg['body'], null, $options); } @@ -1330,7 +1337,7 @@ implements RestrictedAccess { foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[] = $staff->getEmail(); } } @@ -1359,7 +1366,7 @@ implements RestrictedAccess { $msg = $this->replaceVars($msg->asArray(), array('signature' => ($dept && $dept->isPublic())?$dept->getSignature():'')); - $email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body']); + $email->sendAutoReply($this->getOwner(), $msg['subj'], $msg['body']); } $user = $this->getOwner(); @@ -1421,9 +1428,8 @@ implements RestrictedAccess { 'thread' => $entry); foreach ($recipients as $recipient) { if ($uid == $recipient->getUserId()) continue; - $options['references'] = $entry->getEmailReferencesForUser($recipient); $notice = $this->replaceVars($msg, array('recipient' => $recipient)); - $email->send($recipient->getEmail(), $notice['subj'], $notice['body'], $attachments, + $email->send($recipient, $notice['subj'], $notice['body'], $attachments, $options); } @@ -1497,9 +1503,8 @@ implements RestrictedAccess { $options = array( 'inreplyto'=>$message->getEmailMessageId(), - 'references' => $message->getEmailReferencesForUser($user), 'thread'=>$message); - $email->sendAutoReply($user->getEmail(), $msg['subj'], $msg['body'], + $email->sendAutoReply($user, $msg['subj'], $msg['body'], null, $options); } } @@ -1562,7 +1567,7 @@ implements RestrictedAccess { foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[] = $staff->getEmail(); } } @@ -1611,7 +1616,7 @@ implements RestrictedAccess { foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null); $sentlist[] = $staff->getEmail(); } @@ -1802,7 +1807,7 @@ implements RestrictedAccess { foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[] = $staff->getEmail(); } } @@ -1928,7 +1933,8 @@ implements RestrictedAccess { function postMessage($vars, $origin='', $alerts=true) { global $cfg; - $vars['origin'] = $origin; + if ($origin) + $vars['origin'] = $origin; if(isset($vars['ip'])) $vars['ip_address'] = $vars['ip']; elseif(!$vars['ip_address'] && $_SERVER['REMOTE_ADDR']) @@ -2034,7 +2040,7 @@ implements RestrictedAccess { foreach( $recipients as $k=>$staff) { if(!$staff || !$staff->getEmail() || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[] = $staff->getEmail(); } } @@ -2095,7 +2101,7 @@ implements RestrictedAccess { 'inreplyto'=>$response->getEmailMessageId(), 'references'=>$response->getEmailReferences(), 'thread'=>$response); - $email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body'], $attachments, + $email->sendAutoReply($this, $msg['subj'], $msg['body'], $attachments, $options); } @@ -2158,7 +2164,7 @@ implements RestrictedAccess { $variables + array('recipient' => $this->getOwner())); $attachments = $cfg->emailAttachments()?$response->getAttachments():array(); - $email->send($this->getEmail(), $msg['subj'], $msg['body'], $attachments, + $email->send($this->getOwner(), $msg['subj'], $msg['body'], $attachments, $options); } @@ -2221,18 +2227,21 @@ implements RestrictedAccess { ); } - function postNote($vars, &$errors, $poster, $alert=true) { + function postNote($vars, &$errors, $poster=false, $alert=true) { global $cfg, $thisstaff; //Who is posting the note - staff or system? $vars['staffId'] = 0; - $vars['poster'] = 'SYSTEM'; if($poster && is_object($poster)) { $vars['staffId'] = $poster->getId(); $vars['poster'] = $poster->getName(); - }elseif($poster) { //string + } + elseif ($poster) { //string $vars['poster'] = $poster; } + elseif (!isset($vars['poster'])) { + $vars['poster'] = 'SYSTEM'; + } if(!($note=$this->getThread()->addNote($vars, $errors))) return null; @@ -2305,7 +2314,7 @@ implements RestrictedAccess { ) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[$staff->getEmail()] = 1; } } @@ -2313,6 +2322,19 @@ implements RestrictedAccess { return $note; } + // Threadable interface + function postThreadEntry($type, $vars) { + $errors = array(); + switch ($type) { + case 'M': + return $this->postMessage($vars, $vars['origin']); + case 'N': + return $this->postNote($vars, $errors); + case 'R': + return $this->postReply($vars, $errors); + } + } + //Print ticket... export the ticket thread as PDF. function pdfExport($psize='Letter', $notes=false) { global $thisstaff; @@ -3125,7 +3147,7 @@ implements RestrictedAccess { $ticket->logEvent('created'); // Fire post-create signal (for extra email sending, searching) - Signal::send('model.created', $ticket); + Signal::send('ticket.created', $ticket); /* Phew! ... time for tea (KETEPA) */ @@ -3241,9 +3263,9 @@ implements RestrictedAccess { $references[] = $response->getEmailMessageId(); $options = array( 'references' => $references, - 'thread' => $message, + 'thread' => $message ?: $ticket->getThread(), ); - $email->send($ticket->getEmail(), $msg['subj'], $msg['body'], $attachments, + $email->send($ticket->getOwner(), $msg['subj'], $msg['body'], $attachments, $options); } diff --git a/include/class.user.php b/include/class.user.php index 5db55087c1538f53bd2acb0311c3022165d2c7dc..0ccfaf1a7c104c1238b346076b57792be5211307 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -178,6 +178,7 @@ class User extends UserModel { catch (OrmException $e) { return null; } + Signal::send('user.created', $user); } return $user; @@ -716,9 +717,9 @@ class PersonsName { $r = explode(' ', $name); $size = count($r); - + //check if name is bad format (ex: J.Everybody), and fix them - if($size==1 && mb_strpos($r[0], '.') !== false) + if($size==1 && mb_strpos($r[0], '.') !== false) { $r = explode('.', $name); $size = count($r); diff --git a/include/client/templates/ticket-print.tmpl.php b/include/client/templates/ticket-print.tmpl.php index 67e24a1700af259f8e802017fd405675527d9dd7..a1173b217b48111ebc8857129cd9c8e4fe74a56a 100644 --- a/include/client/templates/ticket-print.tmpl.php +++ b/include/client/templates/ticket-print.tmpl.php @@ -198,30 +198,29 @@ $types = array('M', 'R'); if ($thread = $ticket->getThreadEntries($types)) { $threadTypes=array('M'=>'message','R'=>'response', 'N'=>'note'); foreach ($thread as $entry) { ?> - <div class="thread-entry <?php echo $threadTypes[$entry['thread_type']]; ?>"> + <div class="thread-entry <?php echo $threadTypes[$entry->type]; ?>"> <table class="header"><tr><td> <span><?php - echo Format::datetime($entry['created']);?></span> + echo Format::datetime($entry->created);?></span> <span style="padding:0 1em" class="faded title"><?php - echo Format::truncate($entry['title'], 100); ?></span> + echo Format::truncate($entry->title, 100); ?></span> </td> <td class="flush-right faded title" style="white-space:no-wrap"> <?php - echo Format::htmlchars($entry['name'] ?: $entry['poster']); ?></span> + echo Format::htmlchars($entry->getName()); ?></span> </td> </tr></table> <div class="thread-body"> - <div><?php echo $entry['body']->display('pdf'); ?></div> + <div><?php echo $entry->getBody()->display('pdf'); ?></div> </div> <?php - if ($entry['attachments'] - && ($tentry = $ticket->getThreadEntry($entry['id'])) - && ($files = $tentry->getAttachments())) { ?> + if ($entry->has_attachments + && ($files = $entry->attachments)) { ?> <div class="info"> -<?php foreach ($files as $F) { ?> +<?php foreach ($files as $A) { ?> <div> - <span><?php echo $F['name']; ?></span> - <span class="faded">(<?php echo Format::file_size($F['size']); ?>)</span> + <span><?php echo Format::htmlchars($A->file->name); ?></span> + <span class="faded">(<?php echo Format::file_size($A->file->size); ?>)</span> </div> <?php } ?> </div> diff --git a/include/client/view.inc.php b/include/client/view.inc.php index fe2f1b8aa481d93f4178743bfe4808ce82cda4e8..bc331dee94d8eab2efc2a64d6cbdc72ac74b3de4 100644 --- a/include/client/view.inc.php +++ b/include/client/view.inc.php @@ -114,24 +114,23 @@ if($ticket->getThreadCount() && ($thread=$ticket->getClientThread())) { foreach($thread as $entry) { //Making sure internal notes are not displayed due to backend MISTAKES! - if(!$threadType[$entry['type']]) continue; - $poster = $entry['poster']; - if($entry['type']=='R' && ($cfg->hideStaffName() || !$entry['staff_id'])) + if(!$threadType[$entry->type]) continue; + $poster = $entry->poster; + if($entry->type=='R' && ($cfg->hideStaffName() || !$entry->staff_id)) $poster = ' '; ?> - <table class="thread-entry <?php echo $threadType[$entry['type']]; ?>" cellspacing="0" cellpadding="1" width="800" border="0"> + <table class="thread-entry <?php echo $threadType[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="800" border="0"> <tr><th><div> -<?php echo Format::datetime($entry['created']); ?> +<?php echo Format::datetime($entry->created); ?> <span class="textra"></span> <span><?php echo $poster; ?></span> </div> </th></tr> - <tr><td class="thread-body"><div><?php echo Format::clickableurls($entry['body']->toHtml()); ?></div></td></tr> + <tr><td class="thread-body"><div><?php echo Format::clickableurls($entry->getBody()->toHtml()); ?></div></td></tr> <?php - if($entry['attachments'] - && ($tentry=$ticket->getThreadEntry($entry['id'])) - && ($urls = $tentry->getAttachmentUrls()) - && ($links=$tentry->getAttachmentsLinks())) { ?> + if($entry->has_attachments + && ($urls = $entry->getAttachmentUrls()) + && ($links = $entry->getAttachmentsLinks())) { ?> <tr><td class="info"><?php echo $links; ?></td></tr> <?php } if ($urls) { ?> diff --git a/include/staff/templates/task-view.tmpl.php b/include/staff/templates/task-view.tmpl.php index 6020b7a3eadfbec200c2f88ef25fff82ba010340..a9a4c72170780e61cd2edd7ada25a4ab30a6ab3b 100644 --- a/include/staff/templates/task-view.tmpl.php +++ b/include/staff/templates/task-view.tmpl.php @@ -212,32 +212,31 @@ foreach (DynamicFormEntry::forObject($task->getId(), $types = array('M', 'R', 'N'); if(($thread=$task->getThreadEntries($types))) { foreach($thread as $entry) { ?> - <table class="thread-entry <?php echo $threadTypes[$entry['type']]; ?>" cellspacing="0" cellpadding="1" width="940" border="0"> + <table class="thread-entry <?php echo $threadTypes[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="940" border="0"> <tr> <th colspan="4" width="100%"> <div> <span class="pull-left"> <span style="display:inline-block"><?php - echo Format::datetime($entry['created']);?></span> + echo Format::datetime($entry->created);?></span> <span style="display:inline-block;padding:0 1em" class="faded title"><?php - echo Format::truncate($entry['title'], 100); ?></span> + echo Format::truncate($entry->title, 100); ?></span> </span> <span class="pull-right" style="white-space:no-wrap;display:inline-block"> <span style="vertical-align:middle;" class="textra"></span> <span style="vertical-align:middle;" class="tmeta faded title"><?php - echo Format::htmlchars($entry['name'] ?: $entry['poster']); ?></span> + echo Format::htmlchars($entry->getName()); ?></span> </span> </div> </th> </tr> <tr><td colspan="4" class="thread-body" id="thread-id-<?php - echo $entry['id']; ?>"><div><?php - echo $entry['body']->toHtml(); ?></div></td></tr> + echo $entry->getId(); ?>"><div><?php + echo $entry->getBody()->toHtml(); ?></div></td></tr> <?php $urls = null; - if($entry['attachments'] - && ($tentry = $task->getThreadEntry($entry['id'])) + if($entry->has_attachments && ($urls = $tentry->getAttachmentUrls()) && ($links = $tentry->getAttachmentsLinks())) {?> <tr> @@ -246,7 +245,7 @@ foreach (DynamicFormEntry::forObject($task->getId(), } if ($urls) { ?> <script type="text/javascript"> - $('#thread-id-<?php echo $entry['id']; ?>') + $('#thread-id-<?php echo $entry->getId(); ?>') .data('urls', <?php echo JsonDataEncoder::encode($urls); ?>) .data('id', <?php echo $entry['id']; ?>); @@ -255,8 +254,8 @@ foreach (DynamicFormEntry::forObject($task->getId(), } ?> </table> <?php - if ($entry['type'] == 'M') - $msgId = $entry['id']; + if ($entry->type == 'M') + $msgId = $entry->getId(); } } else { echo '<p>'.__('Error fetching thread - get technical help.').'</p>'; diff --git a/include/staff/templates/thread-email-headers.tmpl.php b/include/staff/templates/thread-email-headers.tmpl.php index 6e2f45809e0e9dc7d985b92eff1252fcb52ca7a0..a84216ab46be6c4816a679ada4a1e77ff96e5216 100644 --- a/include/staff/templates/thread-email-headers.tmpl.php +++ b/include/staff/templates/thread-email-headers.tmpl.php @@ -3,7 +3,7 @@ <hr/> <pre style="max-height: 300px; overflow-y: scroll"> -<?php echo $headers; ?> +<?php echo Format::htmlchars($headers); ?> </pre> <hr> diff --git a/include/staff/templates/ticket-print.tmpl.php b/include/staff/templates/ticket-print.tmpl.php index e283e4e8f2da193598e1096b8d1cbe0eb86d00e4..11323c4a14b60077e7e208cc41f0b1434726882e 100644 --- a/include/staff/templates/ticket-print.tmpl.php +++ b/include/staff/templates/ticket-print.tmpl.php @@ -222,29 +222,28 @@ if ($this->includenotes) if ($thread = $ticket->getThreadEntries($types)) { $threadTypes=array('M'=>'message','R'=>'response', 'N'=>'note'); foreach ($thread as $entry) { ?> - <div class="thread-entry <?php echo $threadTypes[$entry['thread_type']]; ?>"> + <div class="thread-entry <?php echo $threadTypes[$entry->type]; ?>"> <table class="header" style="width:100%"><tr><td> <span><?php - echo Format::datetime($entry['created']);?></span> + echo Format::datetime($entry->created);?></span> <span style="padding:0 1em" class="faded title"><?php - echo Format::truncate($entry['title'], 100); ?></span> + echo Format::truncate($entry->title, 100); ?></span> </td> <td class="flush-right faded title" style="white-space:no-wrap"> <?php - echo Format::htmlchars($entry['name'] ?: $entry['poster']); ?></span> + echo Format::htmlchars($entry->getName()); ?></span> </td> </tr></table> <div class="thread-body"> - <div><?php echo $entry['body']->display('pdf'); ?></div> + <div><?php echo $entry->getBody()->display('pdf'); ?></div> <?php - if ($entry['attachments'] - && ($tentry = $ticket->getThreadEntry($entry['id'])) - && ($files = $tentry->getAttachments())) { ?> + if ($entry->has_attachments + && ($files = $entry->attachments)) { ?> <div class="info"> -<?php foreach ($files as $F) { ?> +<?php foreach ($files as $A) { ?> <div> - <span><?php echo $F['name']; ?></span> - <span class="faded">(<?php echo Format::file_size($F['size']); ?>)</span> + <span><?php echo Format::htmlchars($A->file->name); ?></span> + <span class="faded">(<?php echo Format::file_size($A->file->size); ?>)</span> </div> <?php } ?> </div> diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php index eb34d70a3e82222da418dc6c025081487bf4490c..b954c49dcb78c081294149b7013a55d97931435f 100644 --- a/include/staff/ticket-view.inc.php +++ b/include/staff/ticket-view.inc.php @@ -376,8 +376,7 @@ foreach (DynamicFormEntry::forTicket($ticket->getId()) as $form) { <div class="clear"></div> <h2 style="padding:10px 0 5px 0; font-size:11pt;"><?php echo Format::htmlchars($ticket->getSubject()); ?></h2> <?php -$tcount = $ticket->getThreadCount(); -$tcount+= $ticket->getNumNotes(); +$tcount = $ticket->getThreadEntries($types)->count(); ?> <ul class="tabs threads" id="ticket_tabs" > <li class="active"><a href="#ticket_thread"><?php echo sprintf(__('Ticket Thread (%d)'), $tcount); ?></a></li> @@ -396,26 +395,25 @@ $tcount+= $ticket->getNumNotes(); /* -------- Messages & Responses & Notes (if inline)-------------*/ $types = array('M', 'R', 'N'); if(($thread=$ticket->getThreadEntries($types))) { - foreach($thread as $entry) { - $tentry = $ticket->getThreadEntry($entry['id']); ?> - <table class="thread-entry <?php echo $threadTypes[$entry['type']]; ?>" cellspacing="0" cellpadding="1" width="940" border="0"> + foreach($thread as $entry) { ?> + <table class="thread-entry <?php echo $threadTypes[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="940" border="0"> <tr> <th colspan="4" width="100%"> <div> <span class="pull-left"> <span style="display:inline-block"><?php - echo Format::datetime($entry['created']);?></span> + echo Format::datetime($entry->created);?></span> <span style="display:inline-block;padding:0 1em" class="faded title"><?php - echo Format::truncate($entry['title'], 100); ?></span> + echo Format::truncate($entry->title, 100); ?></span> </span> -<?php if ($tentry->hasActions()) { - $actions = $tentry->getActions(); ?> - <div class="pull-right"> - <span class="action-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry['id']; ?>"> + <div class="pull-right"> +<?php if ($entry->hasActions()) { + $actions = $entry->getActions(); ?> + <span class="action-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> <i class="icon-caret-down"></i> <span ><i class="icon-cog"></i></span> </span> - <div id="entry-action-more-<?php echo $entry['id']; ?>" class="action-dropdown anchor-right"> + <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right"> <ul class="title"> <?php foreach ($actions as $group => $list) { foreach ($list as $id => $action) { ?> @@ -434,36 +432,47 @@ $tcount+= $ticket->getNumNotes(); <span style="vertical-align:middle;" class="textra"></span> <span style="vertical-align:middle;" class="tmeta faded title"><?php - echo Format::htmlchars($entry['name'] ?: $entry['poster']); ?></span> + echo Format::htmlchars($entry->getName()); ?></span> </span> </div> </th> </tr> <tr><td colspan="4" class="thread-body" id="thread-id-<?php - echo $entry['id']; ?>"><div><?php - echo Format::clickableurls($entry['body']->toHtml()); ?></div></td></tr> + echo $entry->getId(); ?>"><div><?php + echo Format::clickableurls($entry->getBody()->toHtml()); ?></div></td></tr> <?php $urls = null; - if($entry['attachments'] - && ($urls = $tentry->getAttachmentUrls()) - && ($links = $tentry->getAttachmentsLinks())) {?> + if ($entry->has_attachments + && ($urls = $entry->getAttachmentUrls())) { ?> <tr> - <td class="info" colspan="4"><?php echo $links; ?></td> + <td class="info" colspan="4"><?php + foreach ($entry->attachments as $A) { + if ($A->inline) continue; + $size = ''; + if ($A->file->size) + $size = sprintf('<em>(%s)</em>', + Format::file_size($A->file->size)); +?> + <a class="Icon file no-pjax" href="<?php echo $A->file->getDownloadUrl(); + ?>" target="_blank"><?php echo Format::htmlchars($A->file->name); + ?></a><?php echo $size;?> +<?php } ?> + </td> </tr> <?php } if ($urls) { ?> <script type="text/javascript"> - $('#thread-id-<?php echo $entry['id']; ?>') + $('#thread-id-<?php echo $entry->getId(); ?>') .data('urls', <?php echo JsonDataEncoder::encode($urls); ?>) - .data('id', <?php echo $entry['id']; ?>); + .data('id', <?php echo $entry->getId(); ?>); </script> <?php } ?> </table> <?php - if ($entry['type'] == 'M') - $msgId = $entry['id']; + if ($entry->type == 'M') + $msgId = $entry->getId(); } } else { echo '<p><em>'.__('No entries have been posted to this ticket.').'</em></p>'; diff --git a/include/upgrader/streams/core/15b30765-dd0022fb.task.php b/include/upgrader/streams/core/15b30765-dd0022fb.task.php index 4d7eac01ff94a7622a16b118b5c567d41d3a9af0..0bf1576190be9a1eb3cd3de3a60f87115fa99e00 100644 --- a/include/upgrader/streams/core/15b30765-dd0022fb.task.php +++ b/include/upgrader/streams/core/15b30765-dd0022fb.task.php @@ -188,7 +188,7 @@ class AttachmentMigrater extends MigrationTask { # TODO: Get the size and mime/type of each file. # # NOTE: If filesize() fails and file_get_contents() doesn't, - # then the AttachmentFile::save() method will automatically + # then the AttachmentFile::create() method will automatically # estimate the filesize based on the length of the string data # received in $info['data'] -- ie. no need to do that here. # @@ -228,9 +228,9 @@ class AttachmentMigrater extends MigrationTask { return $this->errorList; } - // This is the AttachmentFile::save() method from osTicket 1.7.6. It's + // This is the AttachmentFile::create() method from osTicket 1.7.6. It's // been ported here so that further changes to the %file table and the - // AttachmentFile::save() method do not affect upgrades from osTicket + // AttachmentFile::create() method do not affect upgrades from osTicket // 1.6 to osTicket 1.8 and beyond. function saveAttachment($file) { diff --git a/include/upgrader/streams/core/934954de-f1ccd3bb.task.php b/include/upgrader/streams/core/934954de-f1ccd3bb.task.php index 7fe0f141ed159be2a6d177219c1e8abed25ce2d8..041bfad9a33730283e1203d6711bbafcaa27a548 100644 --- a/include/upgrader/streams/core/934954de-f1ccd3bb.task.php +++ b/include/upgrader/streams/core/934954de-f1ccd3bb.task.php @@ -4,18 +4,16 @@ class FileImport extends MigrationTask { var $description = "Import core osTicket attachment files"; function run($runtime) { - $errors = array(); - $i18n = new Internationalization('en_US'); $files = $i18n->getTemplate('file.yaml')->getData(); foreach ($files as $f) { - if (!($id = AttachmentFile::create($f, $errors))) + if (!($file = AttachmentFile::create($f))) continue; // Ensure the new files are never deleted (attached to Disk) $sql ='INSERT INTO '.ATTACHMENT_TABLE .' SET object_id=0, `type`=\'D\', inline=1' - .', file_id='.db_input($id); + .', file_id='.db_input($file->getId()); db_query($sql); } }