diff --git a/bootstrap.php b/bootstrap.php
index c53935b13d0eb8a61ac362a29de77a6ef9546e61..03007ba9b7f9f9e6db4d97afecc6a13560188833 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -194,6 +194,7 @@ class Bootstrap {
         require(INCLUDE_DIR.'class.mailer.php');
         require_once INCLUDE_DIR.'mysqli.php';
         require_once INCLUDE_DIR.'class.i18n.php';
+        require_once INCLUDE_DIR.'class.search.php';
     }
 
     function i18n_prep() {
diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php
index 18d49aca3217d362a090e35d02712354de4ca4ae..5834b2377744ebbba350aa6a50947e1e56c7d4af 100644
--- a/include/ajax.tickets.php
+++ b/include/ajax.tickets.php
@@ -98,7 +98,7 @@ class TicketsAjaxAPI extends AjaxController {
     }
 
     function _search($req) {
-        global $thisstaff, $cfg;
+        global $thisstaff, $cfg, $ost;
 
         $result=array();
         $select = 'SELECT ticket.ticket_id';
@@ -189,27 +189,15 @@ class TicketsAjaxAPI extends AjaxController {
             $queryterm=db_real_escape($req['query'], false);
 
             // Setup sets of joins and queries
+            if ($s = $ost->searcher)
+               $ids = $s->find($req['query'], null, 'Ticket');
+
+            if (!$ids)
+                return array();
+
             $joins[] = array(
-                'from' =>
-                    'LEFT JOIN '.TICKET_THREAD_TABLE.' thread ON (ticket.ticket_id=thread.ticket_id )',
-                'where' => "thread.title LIKE '%$queryterm%' OR thread.body LIKE '%$queryterm%'"
-            );
-            $joins[] = array(
-                'from' =>
-                    'LEFT JOIN '.FORM_ENTRY_TABLE.' tentry ON (tentry.object_id = ticket.ticket_id AND tentry.object_type="T")
-                    LEFT JOIN '.FORM_ANSWER_TABLE.' tans ON (tans.entry_id = tentry.id AND tans.value_id IS NULL)',
-                'where' => "tans.value LIKE '%$queryterm%'"
-            );
-            $joins[] = array(
-                'from' =>
-                   'LEFT JOIN '.FORM_ENTRY_TABLE.' uentry ON (uentry.object_id = ticket.user_id
-                   AND uentry.object_type="U")
-                   LEFT JOIN '.FORM_ANSWER_TABLE.' uans ON (uans.entry_id = uentry.id
-                   AND uans.value_id IS NULL)
-                   LEFT JOIN '.USER_TABLE.' user ON (ticket.user_id = user.id)
-                   LEFT JOIN '.USER_EMAIL_TABLE.' uemail ON (user.id = uemail.user_id)',
-                'where' =>
-                    "uemail.address LIKE '%$queryterm%' OR user.name LIKE '%$queryterm%' OR uans.value LIKE '%$queryterm%'",
+                'from' => '',
+                'where' => 'ticket.ticket_id IN (' . implode(',', $ids) . ')'
             );
         }
 
diff --git a/include/class.dynamic_forms.php b/include/class.dynamic_forms.php
index 81c33a6e8741c2932653aa90d9792cf6089bf248..93b9da001a3b9dffcaa7ae5fa1949f1046319f34 100644
--- a/include/class.dynamic_forms.php
+++ b/include/class.dynamic_forms.php
@@ -911,6 +911,14 @@ class DynamicFormEntryAnswer extends VerySimpleModel {
         return $this->getField()->display($this->getValue());
     }
 
+    function getSearchable($include_label=false) {
+        if ($include_label)
+            $label = Format::searchable($this->getField()->getLabel()) . " ";
+        return sprintf("%s%s", $label,
+            $this->getField()->searchable($this->getValue())
+        );
+    }
+
     function asVar() {
         return (is_object($this->getValue()))
             ? $this->getValue() : $this->toString();
diff --git a/include/class.faq.php b/include/class.faq.php
index 1786c79f66e91166075dd3ae4fe19faa7083dd25..78d221c21b1e7ec2ea267da70401aa7ce57ac612 100644
--- a/include/class.faq.php
+++ b/include/class.faq.php
@@ -63,6 +63,10 @@ class FAQ {
     function getAnswerWithImages() {
         return Format::viewableImages($this->ht['answer'], ROOT_PATH.'image.php');
     }
+    function getSearchableAnswer() {
+        return ThreadBody::fromFormattedText($this->ht['answer'], 'html')
+            ->getSearchable();
+    }
     function getNotes() { return $this->ht['notes']; }
     function getNumAttachments() { return $this->ht['attachments']; }
 
@@ -153,9 +157,10 @@ class FAQ {
         if($ids)
             $sql.=' AND topic_id NOT IN('.implode(',', db_input($ids)).')';
 
-        db_query($sql);
+        if (!db_query($sql))
+            return false;
 
-        return true;
+        Signal::send('model.updated', $this);
     }
 
     function update($vars, &$errors) {
@@ -186,6 +191,7 @@ class FAQ {
 
         $this->reload();
 
+        Signal::send('model.updated', $this);
         return true;
     }
 
@@ -319,8 +325,10 @@ class FAQ {
 
         } else {
             $sql='INSERT INTO '.FAQ_TABLE.' SET '.$sql.',created=NOW()';
-            if(db_query($sql) && ($id=db_insert_id()))
+            if (db_query($sql) && ($id=db_insert_id())) {
+                Signal::send('model.created', FAQ::lookup($id));
                 return $id;
+            }
 
             $errors['err']=sprintf(__('Unable to create %s.'), __('this FAQ article'))
                .' '.__('Internal error occurred');
diff --git a/include/class.format.php b/include/class.format.php
index 0aa52abccbd6bdf91e98aa136b0bfd473fa9085d..d0e4ba203ecac6ff329045fa43be9d1c51bc307e 100644
--- a/include/class.format.php
+++ b/include/class.format.php
@@ -569,5 +569,58 @@ class Format {
         );
     }
 
+    // Performs Unicode normalization (where possible) and splits words at
+    // difficult word boundaries (for far eastern languages)
+    function searchable($text) {
+        if (function_exists('normalizer_normalize')) {
+            // Normalize text input :: remove diacritics and such
+            $text = normalizer_normalize($text, Normalizer::FORM_C);
+        }
+        else {
+            // As a lightweight compatiblity, use a lightweight C
+            // normalizer with diacritic removal, thanks
+            // http://ahinea.com/en/tech/accented-translate.html
+            $tr = array(
+                "ä" => "a", "ñ" => "n", "ö" => "o", "ü" => "u", "ÿ" => "y"
+            );
+            $text = strtr($text, $tr);
+        }
+        // Decompose compatible versions of characters (ä => ae)
+        $tr = array(
+            "ß" => "ss", "Æ" => "AE", "æ" => "ae", "IJ" => "IJ",
+            "ij" => "ij", "Œ" => "OE", "œ" => "oe", "Ð" => "D",
+            "Đ" => "D", "ð" => "d", "đ" => "d", "Ħ" => "H", "ħ" => "h",
+            "ı" => "i", "ĸ" => "k", "Ŀ" => "L", "Ł" => "L", "ŀ" => "l",
+            "ł" => "l", "Ŋ" => "N", "ʼn" => "n", "ŋ" => "n", "Ø" => "O",
+            "ø" => "o", "ſ" => "s", "Þ" => "T", "Ŧ" => "T", "þ" => "t",
+            "ŧ" => "t", "ä" => "ae", "ö" => "oe", "ü" => "ue",
+            "Ä" => "AE", "Ö" => "OE", "Ü" => "UE",
+        );
+        $text = strtr($text, $tr);
+
+        // Drop separated diacritics
+        $text = preg_replace('/\p{M}/u', '', $text);
+
+        // Drop extraneous whitespace
+        $text = preg_replace('/(\s)\s+/u', '$1', $text);
+
+        // Drop leading and trailing whitespace
+        $text = trim($text);
+
+        if (class_exists('IntlBreakIterator')) {
+            // Split by word boundaries
+            if ($tokenizer = IntlBreakIterator::createWordInstance()) {
+                $tokenizer->setText($text);
+                $text = implode(' ', $tokenizer);
+            }
+        }
+        else {
+            // Approximate word boundaries from Unicode chart at
+            // http://www.unicode.org/reports/tr29/#Word_Boundaries
+
+            // Punt for now
+        }
+        return $text;
+    }
 }
 ?>
diff --git a/include/class.forms.php b/include/class.forms.php
index 8611145013804cfa606abf957a59c6c137066f1b..ebc828bb252106c6b69c114dd4d50c1888a36738 100644
--- a/include/class.forms.php
+++ b/include/class.forms.php
@@ -338,6 +338,10 @@ class FormField {
         return $this->toString($this->getClean());
     }
 
+    function searchable($value) {
+        return Format::searchable($this->toString($value));
+    }
+
     function getLabel() { return $this->get('label'); }
 
     /**
@@ -621,6 +625,12 @@ class TextareaField extends FormField {
             return nl2br(Format::htmlchars($value));
     }
 
+    function searchable($value) {
+        $value = preg_replace(array('`<br(\s*)?/?>`i', '`</div>`i'), "\n", $value);
+        $value = Format::htmldecode(Format::striptags($value));
+        return Format::searchable($value);
+    }
+
     function export($value) {
         return (!$value) ? $value : Format::html2text($value);
     }
@@ -987,6 +997,11 @@ class PriorityField extends ChoiceField {
         return ($value instanceof Priority) ? $value->getDesc() : $value;
     }
 
+    function searchable($value) {
+        // Priority isn't searchable this way
+        return null;
+    }
+
     function getConfigurationOptions() {
         return array(
             'prompt' => new TextboxField(array(
diff --git a/include/class.organization.php b/include/class.organization.php
index 37cedebdf2dc395f13c06f0c857b9d50c2495e20..617c480311394d5ca6ba0e568cc01cd61aa34cca 100644
--- a/include/class.organization.php
+++ b/include/class.organization.php
@@ -318,6 +318,11 @@ class Organization extends OrganizationModel {
             }
         }
 
+        // Send signal for search engine updating if not modifying the
+        // fields specific to the organization
+        if (count($this->dirty) === 0)
+            Signal::send('model.updated', $this);
+
         return $this->save();
     }
 
diff --git a/include/class.osticket.php b/include/class.osticket.php
index 9f8870547699b8b2ac8d67fa4fc933921fe24c32..e3901f25d34b1748db66f4a2fa416e9c786c5a87 100644
--- a/include/class.osticket.php
+++ b/include/class.osticket.php
@@ -457,6 +457,9 @@ class osTicket {
         // Bootstrap installed plugins
         $ost->plugins->bootstrap();
 
+        // Mirror content updates to the search backend
+        $ost->searcher = new SearchInterface();
+
         return $ost;
     }
 }
diff --git a/include/class.search.php b/include/class.search.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d1a210fac10ef4005ed6cb003b4314945976025
--- /dev/null
+++ b/include/class.search.php
@@ -0,0 +1,517 @@
+<?php
+/*********************************************************************
+    module.search.php
+
+    Search Engine for osTicket
+
+    This module defines the pieces for a search engine for osTicket.
+    Searching can be performed by various search engine backends which can
+    make use of the features of various search providers.
+
+    A reference search engine backend is provided which uses MySQL MyISAM
+    tables. This default backend should not be used on Galera clusters.
+
+    Jared Hancock <jared@osticket.com>
+    Peter Rotich <peter@osticket.com>
+    Copyright (c)  2006-2013 osTicket
+    http://www.osticket.com
+
+    Released under the GNU General Public License WITHOUT ANY WARRANTY.
+    See LICENSE.TXT for details.
+
+    vim: expandtab sw=4 ts=4 sts=4:
+**********************************************************************/
+
+abstract class SearchBackend {
+    static $id = false;
+    static $registry = array();
+
+    const SORT_RELEVANCE = 1;
+    const SORT_RECENT = 2;
+    const SORT_OLDEST = 3;
+
+    abstract function update($model, $id, $content, $new=false, $attrs=array());
+    abstract function find($query, $criteria, $model=false, $sort=array());
+
+    function register($backend=false) {
+        $backend = $backend ?: get_called_class();
+
+        if ($backend::$id == false)
+            throw new Exception('SearchBackend must define an ID');
+
+        static::$registry[$backend::$id] = $backend;
+    }
+
+    function getInstance($id) {
+        if (!isset(self::$registry[$id]))
+            return null;
+
+        return new self::$registry[$id]();
+    }
+}
+
+// Register signals to intercept saving of various content throughout the
+// system
+
+class SearchInterface {
+
+    var $backend;
+
+    function __construct() {
+        $this->bootstrap();
+    }
+
+    function find($query, $criteria, $model=false, $sort=array()) {
+        return $this->backend->find($query, $criteria, $model, $sort);
+    }
+
+    function update($model, $id, $content, $new=false, $attrs=array()) {
+        if (!$this->backend)
+            return;
+
+        $this->backend->update($model, $id, $content, $new, $attrs);
+    }
+
+    function createModel($model) {
+        return $this->updateModel($model, true);
+    }
+
+    function updateModel($model, $new=false) {
+        // The MySQL backend does not need to index attributes of the
+        // various models, because those other attributes are available in
+        // the local database in other tables.
+        switch (true) {
+        case $model instanceof ThreadEntry:
+            // Only index an entry for threads if a human created the
+            // content
+            if (!$model->getUserId() && !$model->getStaffId())
+                break;
+
+            $this->update($model, $model->getId(),
+                $model->getBody()->getSearchable(), $new,
+                array(
+                    'title' =>      $model->getTitle(),
+                    'ticket_id' =>  $model->getTicketId(),
+                    'created' =>    $model->getCreateDate(),
+                )
+            );
+            break;
+
+        case $model instanceof Ticket:
+            $cdata = array();
+            foreach ($model->loadDynamicData() as $a)
+                if ($v = $a->getSearchable())
+                    $cdata[] = $v;
+            $this->update($model, $model->getId(),
+                trim(implode("\n", $cdata)),
+                $new,
+                array(
+                    'title'=>       Format::searchable($model->getSubject()),
+                    'status'=>      $model->getStatus(),
+                    'topic_id'=>    $model->getTopicId(),
+                    'priority_id'=> $model->getPriorityId(),
+                    // Stats (comments, attachments)
+                    // Access constraints
+                    'dept_id'=>     $model->getDeptId(),
+                    'staff_id'=>    $model->getStaffId(),
+                    'team_id'=>     $model->getTeamId(),
+                    // Sorting and ranging preferences
+                    'created'=>     $model->getCreateDate(),
+                    // Add last-updated timestamp
+                )
+            );
+            break;
+
+        case $model instanceof User:
+            $cdata = array();
+            foreach ($model->getDynamicData() as $e)
+                foreach ($e->getAnswers() as $tag=>$a)
+                    if ($tag != 'subject' && ($v = $a->getSearchable()))
+                        $cdata[] = $v;
+            $this->update($model, $model->getId(),
+                trim(implode("\n", $cdata)),
+                $new,
+                array(
+                    'title'=>       Format::searchable($model->getFullName()),
+                    'emails'=>      $model->emails->asArray(),
+                    'org_id'=>      $model->getOrgId(),
+                    'created'=>     $model->getCreateDate(),
+                )
+            );
+            break;
+
+        case $model instanceof Organization:
+            $cdata = array();
+            foreach ($model->getDynamicData() as $e)
+                foreach ($e->getAnswers() as $a)
+                    if ($v = $a->getSearchable())
+                        $cdata[] = $v;
+            $this->update($model, $model->getId(),
+                trim(implode("\n", $cdata)),
+                $new,
+                array(
+                    'title'=>       Format::searchable($model->getName()),
+                    'created'=>     $model->getCreateDate(),
+                )
+            );
+            break;
+
+        case $model instanceof FAQ:
+            $this->update($model, $model->getId(),
+                $model->getSearchableAnswer(),
+                $new,
+                array(
+                    'title'=>       Format::searchable($model->getQuestion()),
+                    'keywords'=>    $model->getKeywords(),
+                    'topics'=>      $model->getHelpTopicsIds(),
+                    'category_id'=> $model->getCategoryId(),
+                    'created'=>     $model->getCreateDate(),
+                )
+            );
+            break;
+
+        default:
+            // Not indexed
+            break;
+        }
+    }
+
+    function bootstrap() {
+        // Determine the backend
+        if (defined('SEARCH_BACKEND'))
+            $bk = SearchBackend::getInstance(SEARCH_BACKEND);
+
+        if (!$bk && !($bk = SearchBackend::getInstance('mysql')))
+            // No backend registered or defined
+            return false;
+
+        $this->backend = $bk;
+        $this->backend->bootstrap();
+
+        $self = $this;
+
+        // Thread entries
+        // Tickets, which can be edited as well
+        // Knowledgebase articles (FAQ and canned responses)
+        // Users, organizations
+        Signal::connect('model.created', array($this, 'createModel'));
+        Signal::connect('model.updated', array($this, 'updateModel'));
+        Signal::connect('model.deleted', array($this, 'deleteModel'));
+    }
+}
+
+class MysqlSearchBackend extends SearchBackend {
+    static $id = 'mysql';
+    static $BATCH_SIZE = 30;
+
+    // Only index 20 batches per cron run
+    var $max_batches = 60;
+
+    function __construct() {
+        $this->SEARCH_TABLE = TABLE_PREFIX . '_search';
+    }
+
+    function bootstrap() {
+        Signal::connect('cron', array($this, 'IndexOldStuff'));
+    }
+
+    function update($model, $id, $content, $new=false, $attrs=array()) {
+        switch (true) {
+        case $model instanceof ThreadEntry:
+            $type = 'H';
+            break;
+        case $model instanceof Ticket:
+            $type = 'T';
+            break;
+        case $model instanceof User:
+            $content .= implode("\n", $attrs['emails']);
+            $type = 'U';
+            break;
+        case $model instanceof Organization:
+            $type = 'O';
+            break;
+        case $model instanceof FAQ:
+            $type = 'K';
+            break;
+        case $model instanceof AttachmentFile:
+            $type = 'F';
+            break;
+        default:
+            // Not indexed
+            return;
+        }
+
+        $title = $attrs['title'] ?: '';
+
+        if (!$content && !$title)
+            return;
+
+        $sql = 'REPLACE INTO '.$this->SEARCH_TABLE
+            . ' SET object_type='.db_input($type)
+            . ', object_id='.db_input($id)
+            . ', content='.db_input($content)
+            . ', title='.db_input($title);
+        return db_query($sql);
+    }
+
+    function find($query, $criteria=array(), $model=false, $sort=array()) {
+        global $thisstaff;
+
+        $tables = array();
+        $mode = ' IN BOOLEAN MODE';
+        #if (count(explode(' ', $query)) == 1)
+        #    $mode = ' WITH QUERY EXPANSION';
+        $where = array('MATCH (search.title, search.content) AGAINST ('
+            .db_input($query)
+            .$mode.') ');
+        $fields = array($where[0] . ' AS `relevance`');
+
+        switch ($model) {
+        case false:
+        case 'Ticket':
+            $tables[] = ORGANIZATION_TABLE . " A4 ON (A4.id = `search`.object_id
+                AND `search`.object_type = 'O')";
+            $tables[] = USER_TABLE . " A3 ON ((A3.id = `search`.object_id
+                AND `search`.object_type = 'U') OR A3.org_id = A4.id)";
+            $tables[] = TICKET_THREAD_TABLE . " A2 ON (A2.id = `search`.object_id
+                AND `search`.object_type = 'H')";
+            $tables[] = TICKET_TABLE . " A1 ON ((A1.ticket_id = `search`.object_id
+                AND `search`.object_type='T')
+                OR (A4.id = A3.org_id AND A1.user_id = A3.id)
+                OR A3.id = A1.user_id
+                OR A2.ticket_id = A1.ticket_id)";
+            $fields[] = 'A1.`ticket_id`';
+            $key = 'ticket_id';
+
+            if ($criteria) {
+                foreach ($criteria as $name=>$value) {
+                    switch ($name) {
+                    case 'status':
+                    case 'topic_id':
+                    case 'staff_id':
+                    case 'dept_id':
+                    case 'user_id':
+                        $where[] = sprintf('A1.%s = %s', $name, db_input($value));
+                        break;
+                    case 'email':
+                    case 'org_id':
+                    case 'form_id':
+                    }
+                }
+            }
+
+            // Always consider the current staff's access
+            $thisstaff->getDepts();
+            $access = array();
+            $access[] = '(A1.staff_id=' . db_input($thisstaff->getId())
+                .' AND A1.status="open")';
+
+            if (!$thisstaff->showAssignedOnly() && ($depts=$thisstaff->getDepts()))
+                $access[] = 'A1.dept_id IN ('
+                    . ($depts ? implode(',', db_input($depts)) : 0)
+                    . ')';
+
+            if (($teams = $thisstaff->getTeams()) && count(array_filter($teams)))
+                $access[] = 'A1.team_id IN ('
+                    .implode(',', db_input(array_filter($teams)))
+                    .') AND A1.status="open"';
+
+            $where[] = '(' . implode(' OR ', $access) . ')';
+
+            $sql = 'SELECT DISTINCT '
+                . $key
+                . ' FROM ( SELECT '
+                . implode(', ', $fields)
+                . ' FROM `'.TABLE_PREFIX.'_search` `search` '
+                . (count($tables) ? ' LEFT JOIN ' : '')
+                . implode(' LEFT JOIN ', $tables)
+                . ' WHERE ' . implode(' AND ', $where)
+                // TODO: Consider sorting preferences
+                . ' ORDER BY `relevance` DESC'
+                . ') __ LIMIT 500';
+        }
+
+        $res = db_query($sql);
+        $object_ids = array();
+
+        while ($row = db_fetch_row($res))
+            $object_ids[] = $row[0];
+
+        return $object_ids;
+    }
+
+    static function createSearchTable() {
+        $sql = 'CREATE TABLE '.TABLE_PREFIX.'_search (
+            `object_type` varchar(8) not null,
+            `object_id` int(11) unsigned not null,
+            `title` text collate utf8_general_ci,
+            `content` text collate utf8_general_ci,
+            primary key `object` (`object_type`, `object_id`),
+            fulltext key `search` (`title`, `content`)
+        ) ENGINE=MyISAM CHARSET=utf8';
+        return db_query($sql);
+    }
+
+    /**
+     * Cooperates with the cron system to automatically find content that is
+     * not index in the _search table and add it to the index.
+     */
+    function IndexOldStuff() {
+        print 'Indexing old stuff!';
+
+        $class = get_class();
+        $auto_create = function($db_error) use ($class) {
+
+            if ($db_error != 1146)
+                // Perform the standard error handling
+                return true;
+
+            // Create the search table automatically
+            $class::createSearchTable();
+        };
+
+        // THREADS ----------------------------------
+
+        $sql = "SELECT A1.`id`, A1.`title`, A1.`body`, A1.`format` FROM `".TICKET_THREAD_TABLE."` A1
+            LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='H')
+            WHERE A2.`object_id` IS NULL AND (A1.poster <> 'SYSTEM')
+            AND (LENGTH(A1.`title`) + LENGTH(A1.`body`) > 0)
+            ORDER BY A1.`id` DESC";
+        if (!($res = db_query_unbuffered($sql, $auto_create)))
+            return false;
+
+        while ($row = db_fetch_row($res)) {
+            $body = ThreadBody::fromFormattedText($row[2], $row[3]);
+            $body = $body->getSearchable();
+            $title = Format::searchable($row[1]);
+            if (!$body && !$title)
+                continue;
+            $records[] = array('H', $row[0], $title, $body);
+            if (count($records) > self::$BATCH_SIZE)
+                if (null === ($records = self::__searchFlush($records)))
+                    return;
+        }
+        $records = self::__searchFlush($records);
+
+        // TICKETS ----------------------------------
+
+        $sql = "SELECT A1.`ticket_id` FROM `".TICKET_TABLE."` A1
+            LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`ticket_id` = A2.`object_id` AND A2.`object_type`='T')
+            WHERE A2.`object_id` IS NULL
+            ORDER BY A1.`ticket_id` DESC";
+        if (!($res = db_query_unbuffered($sql, $auto_create)))
+            return false;
+
+        while ($row = db_fetch_row($res)) {
+            $ticket = Ticket::lookup($row[0]);
+            $cdata = $ticket->loadDynamicData();
+            $content = array();
+            foreach ($cdata as $k=>$a)
+                if ($k != 'subject' && ($v = $a->getSearchable()))
+                    $content[] = $v;
+            $records[] = array('T', $ticket->getId(),
+                Format::searchable($ticket->getSubject()),
+                implode("\n", $content));
+            if (count($records) > self::$BATCH_SIZE)
+                if (null === ($records = self::__searchFlush($records)))
+                    return;
+        }
+        $records = self::__searchFlush($records);
+
+        // USERS ------------------------------------
+
+        $sql = "SELECT A1.`id` FROM `".USER_TABLE."` A1
+            LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='U')
+            WHERE A2.`object_id` IS NULL
+            ORDER BY A1.`id` DESC";
+        if (!($res = db_query_unbuffered($sql, $auto_create)))
+            return false;
+
+        while ($row = db_fetch_row($res)) {
+            $user = User::lookup($row[0]);
+            $cdata = $user->getDynamicData();
+            $content = array();
+            foreach ($user->emails as $e)
+                $content[] = $e->address;
+            foreach ($cdata as $e)
+                foreach ($e->getAnswers() as $a)
+                    if ($c = $a->getSearchable())
+                        $content[] = $c;
+            $records[] = array('U', $user->getId(),
+                Format::searchable($user->getFullName()),
+                trim(implode("\n", $content)));
+            if (count($records) > self::$BATCH_SIZE)
+                if (null === ($records = self::__searchFlush($records)))
+                    return;
+        }
+        $records = self::__searchFlush($records);
+
+        // ORGANIZATIONS ----------------------------
+
+        $sql = "SELECT A1.`id` FROM `".ORGANIZATION_TABLE."` A1
+            LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='O')
+            WHERE A2.`object_id` IS NULL
+            ORDER BY A1.`id` DESC";
+        if (!($res = db_query_unbuffered($sql, $auto_create)))
+            return false;
+
+        while ($row = db_fetch_row($res)) {
+            $org = Organization::lookup($row[0]);
+            $cdata = $org->getDynamicData();
+            $content = array();
+            foreach ($cdata as $e)
+                foreach ($e->getAnswers() as $a)
+                    if ($c = $a->getSearchable())
+                        $content[] = $c;
+            $records[] = array('O', $org->getId(),
+                Format::searchable($org->getName()),
+                trim(implode("\n", $content)));
+            if (count($records) > self::$BATCH_SIZE)
+                $records = self::__searchFlush($records);
+        }
+        $records = self::__searchFlush($records);
+
+        // KNOWLEDGEBASE ----------------------------
+
+        require_once INCLUDE_DIR . 'class.faq.php';
+        $sql = "SELECT A1.`faq_id` FROM `".FAQ_TABLE."` A1
+            LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`faq_id` = A2.`object_id` AND A2.`object_type`='K')
+            WHERE A2.`object_id` IS NULL
+            ORDER BY A1.`faq_id` DESC";
+        if (!($res = db_query_unbuffered($sql, $auto_create)))
+            return false;
+
+        while ($row = db_fetch_row($res)) {
+            $faq = FAQ::lookup($row[0]);
+            $records[] = array('K', $faq->getId(),
+                Format::searchable($faq->getQuestion()),
+                $faq->getSearchableAnswer());
+            if (count($records) > self::$BATCH_SIZE)
+                if (null === ($records = self::__searchFlush($records)))
+                    return;
+        }
+        $records = self::__searchFlush($records);
+
+        // FILES ------------------------------------
+    }
+
+    function __searchFlush($records) {
+        if (!$records)
+            return $records;
+
+        foreach ($records as &$r)
+            $r = sprintf('(%s)', implode(',', db_input($r)));
+        unset($r);
+
+        $sql = 'INSERT INTO `'.TABLE_PREFIX.'_search` (`object_type`, `object_id`, `title`, `content`)
+            VALUES '.implode(',', $records);
+        if (!db_query($sql) || count($records) != db_affected_rows())
+            throw new Exception('Unable to index content');
+
+        if (!--$this->max_batches)
+            return null;
+
+        return array();
+    }
+}
+MysqlSearchBackend::register();
diff --git a/include/class.thread.php b/include/class.thread.php
index 4a920df4831040c6c67d2345caab25432129afb2..ad9752d0a8572a847fb4db014d9caa2ea05e937d 100644
--- a/include/class.thread.php
+++ b/include/class.thread.php
@@ -1096,6 +1096,8 @@ Class ThreadEntry {
         // Inline images (attached to the draft)
         $entry->saveAttachments(Draft::getAttachmentIds($body));
 
+        Signal::send('model.created', $entry);
+
         return $entry;
     }
 
@@ -1363,8 +1365,7 @@ class ThreadBody /* extends SplString */ {
     }
 
     function getSearchable() {
-        return $this->body;
-        // TODO: Normalize Unicode string
+        return Format::searchable($this->body);
     }
 
     static function fromFormattedText($text, $format=false) {
@@ -1436,9 +1437,9 @@ class HtmlThreadBody extends ThreadBody {
 
     function getSearchable() {
         // <br> -> \n
-        $body = preg_replace('/\<br(\s*)?\/?\>/i', "\n", $this->body);
-        return Format::striptags($body);
-        // TODO: Normalize Unicode string
+        $body = preg_replace(array('`<br(\s*)?/?>`i', '`</div>`i'), "\n", $this->body);
+        $body = Format::htmldecode(Format::striptags($body));
+        return Format::searchable($body);
     }
 
     function display($output=false) {
diff --git a/include/class.ticket.php b/include/class.ticket.php
index 06ffa295e1eeccd2a1ad221a2a84ea9dd0bcadde..b10f78097376e255c844045e095da3dcf1727921 100644
--- a/include/class.ticket.php
+++ b/include/class.ticket.php
@@ -115,11 +115,15 @@ class Ticket {
 
     function loadDynamicData() {
         if (!$this->_answers) {
-            foreach (DynamicFormEntry::forTicket($this->getId(), true) as $form)
-                foreach ($form->getAnswers() as $answer)
-                    if ($tag = mb_strtolower($answer->getField()->get('name')))
+            foreach (DynamicFormEntry::forTicket($this->getId(), true) as $form) {
+                foreach ($form->getAnswers() as $answer) {
+                    $tag = mb_strtolower($answer->getField()->get('name'))
+                        ?: 'field.' . $answer->getField()->get('id');
                         $this->_answers[$tag] = $answer;
+                }
+            }
         }
+        return $this->_answers;
     }
 
     function reload() {
@@ -2062,6 +2066,17 @@ class Ticket {
                 $errors['duedate']=__('Due date must be in the future');
         }
 
+        // Validate dynamic meta-data
+        $forms = DynamicFormEntry::forTicket($this->getId());
+        foreach ($forms as $form) {
+            // Don't validate deleted forms
+            if (!in_array($form->getId(), $vars['forms']))
+                continue;
+            $form->setSource($_POST);
+            if (!$form->isValid())
+                $errors = array_merge($errors, $form->errors());
+        }
+
         if($errors) return false;
 
         $sql='UPDATE '.TICKET_TABLE.' SET updated=NOW() '
@@ -2089,6 +2104,19 @@ class Ticket {
         // Decide if we need to keep the just selected SLA
         $keepSLA = ($this->getSLAId() != $vars['slaId']);
 
+        // Update dynamic meta-data
+        foreach ($forms as $f) {
+            // Drop deleted forms
+            $idx = array_search($f->getId(), $vars['forms']);
+            if ($idx === false) {
+                $f->delete();
+            }
+            else {
+                $f->set('sort', $idx);
+                $f->save();
+            }
+        }
+
         // Reload the ticket so we can do further checking
         $this->reload();
 
@@ -2105,6 +2133,7 @@ class Ticket {
             $this->clearOverdue();
         }
 
+        Signal::send('model.updated', $this);
         return true;
     }
 
@@ -2302,6 +2331,8 @@ class Ticket {
             return 0;
         };
 
+        Signal::send('ticket.create.before', null, $vars);
+
         // Create and verify the dynamic form entry for the new ticket
         $form = TicketForm::getNewInstance();
         // If submitting via email, ensure we have a subject and such
@@ -2464,6 +2495,8 @@ class Ticket {
         if ($errors)
             return 0;
 
+        Signal::send('ticket.create.validated', null, $vars);
+
         # Some things will need to be unpacked back into the scope of this
         # function
         if (isset($vars['autorespond']))
@@ -2677,6 +2710,9 @@ class Ticket {
         /* Start tracking ticket lifecycle events */
         $ticket->logEvent('created');
 
+        // Fire post-create signal (for extra email sending, searching)
+        Signal::send('model.created', $ticket);
+
         /* Phew! ... time for tea (KETEPA) */
 
         return $ticket;
diff --git a/include/class.user.php b/include/class.user.php
index 0d13cef637a8bc6c2f3a5071f86abc21b5dbd860..0ec1179fafa03282816a1393c8eefcbf737e02a6 100644
--- a/include/class.user.php
+++ b/include/class.user.php
@@ -26,6 +26,10 @@ class UserEmailModel extends VerySimpleModel {
             )
         )
     );
+
+    function __toString() {
+        return $this->address;
+    }
 }
 
 class TicketModel extends VerySimpleModel {
diff --git a/include/mysqli.php b/include/mysqli.php
index 6b6d25c4ecaeb73bd98154e9f6d13592e016d119..71681975cd8bdd8e1281a3290eac9234690fa6af 100644
--- a/include/mysqli.php
+++ b/include/mysqli.php
@@ -189,6 +189,10 @@ function db_query($query, $logError=true, $buffered=true) {
     return $res;
 }
 
+function db_query_unbuffered($sql, $logError=false) {
+    return db_query($sql, $logError, true);
+}
+
 function db_squery($query) { //smart db query...utilizing args and sprintf
 
     $args  = func_get_args();
diff --git a/scp/tickets.php b/scp/tickets.php
index c5c5c2dd626cd1e44bb614ff32de975ed08a4ed5..f3502c96a2666cfcbdd8868d0a276ad03055c4cd 100644
--- a/scp/tickets.php
+++ b/scp/tickets.php
@@ -187,32 +187,12 @@ if($_POST && !$errors):
             break;
         case 'edit':
         case 'update':
-            $forms=DynamicFormEntry::forTicket($ticket->getId());
-            foreach ($forms as $form) {
-                // Don't validate deleted forms
-                if (!in_array($form->getId(), $_POST['forms']))
-                    continue;
-                $form->setSource($_POST);
-                if (!$form->isValid())
-                    $errors = array_merge($errors, $form->errors());
-            }
             if(!$ticket || !$thisstaff->canEditTickets())
                 $errors['err']=__('Permission Denied. You are not allowed to edit tickets');
             elseif($ticket->update($_POST,$errors)) {
                 $msg=__('Ticket updated successfully');
                 $_REQUEST['a'] = null; //Clear edit action - going back to view.
                 //Check to make sure the staff STILL has access post-update (e.g dept change).
-                foreach ($forms as $f) {
-                    // Drop deleted forms
-                    $idx = array_search($f->getId(), $_POST['forms']);
-                    if ($idx === false) {
-                        $f->delete();
-                    }
-                    else {
-                        $f->set('sort', $idx);
-                        $f->save();
-                    }
-                }
                 if(!$ticket->checkStaffAccess($thisstaff))
                     $ticket=null;
             } elseif(!$errors['err']) {