diff --git a/include/ajax.content.php b/include/ajax.content.php
index dc1bc9ab1f9678913ee1581e3ea672d879d53671..0706757641e7bd82c5603218bafb76110aa92971 100644
--- a/include/ajax.content.php
+++ b/include/ajax.content.php
@@ -13,9 +13,10 @@
 
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
-
 if(!defined('INCLUDE_DIR')) die('!');
 
+require_once INCLUDE_DIR.'class.ajax.php';
+
 class ContentAjaxAPI extends AjaxController {
 
     function log($id) {
@@ -204,5 +205,22 @@ class ContentAjaxAPI extends AjaxController {
         $errors = Format::htmlchars($errors);
         include STAFFINC_DIR . 'templates/content-manage.tmpl.php';
     }
+
+    function context() {
+        global $thisstaff;
+
+        if (!$thisstaff)
+            Http::response(403, 'Login Required');
+        if (!$_GET['root'])
+            Http::response(400, '`root` is required parameter');
+
+        $items = VariableReplacer::getContextForRoot($_GET['root']);
+
+        if (!$items)
+            Http::response(422, 'No such context');
+
+        header('Content-Type: application/json');
+        return $this->encode($items);
+    }
 }
 ?>
diff --git a/include/class.client.php b/include/class.client.php
index 25efc2ca60132537015107d13d273b913bc13ee7..25b03281b9abd31d359d265592dcdc7058061a24 100644
--- a/include/class.client.php
+++ b/include/class.client.php
@@ -16,7 +16,7 @@
 require_once INCLUDE_DIR.'class.user.php';
 
 abstract class TicketUser
-implements EmailContact, ITicketUser {
+implements EmailContact, ITicketUser, TemplateVariable {
 
     static private $token_regex = '/^(?P<type>\w{1})(?P<algo>\d+)x(?P<hash>.*)$/i';
 
@@ -55,6 +55,19 @@ implements EmailContact, ITicketUser {
 
     }
 
+    // Required for Internationalization::getCurrentLanguage() in templates
+    function getLanguage() {
+        return $this->user->getLanguage();
+    }
+
+    static function getVarScope() {
+        return array(
+            'email' => __('Email address'),
+            'name' => array('class' => 'PersonsName', 'desc' => __('Full name')),
+            'ticket_link' => __('Auth. token used for auto-login'),
+        );
+    }
+
     function getId() { return ($this->user) ? $this->user->getId() : null; }
     function getEmail() { return ($this->user) ? $this->user->getEmail() : null; }
 
diff --git a/include/class.company.php b/include/class.company.php
index c9aa22f04af3889ea22b564f06ffdf2dff936a71..6773bd80e83ef59e724744fd9b33663c0f269b57 100644
--- a/include/class.company.php
+++ b/include/class.company.php
@@ -17,7 +17,8 @@
 require_once(INCLUDE_DIR.'class.forms.php');
 require_once(INCLUDE_DIR.'class.dynamic_forms.php');
 
-class Company {
+class Company
+implements TemplateVariable {
     var $form;
     var $entry;
 
@@ -59,6 +60,12 @@ class Company {
         return $this->getName();
     }
 
+    static function getVarScope() {
+        return VariableReplacer::compileFormScope(
+            DynamicForm::lookup(array('type'=>'C'))
+        );
+    }
+
     function __toString() {
         try {
             if ($name = $this->getForm()->getAnswer('name'))
diff --git a/include/class.dept.php b/include/class.dept.php
index 39f914f9f401233909742e991d528c7b6551c1c3..43a98692d1af69cf0c67e2dd3d90dac30d0741ae 100644
--- a/include/class.dept.php
+++ b/include/class.dept.php
@@ -14,7 +14,8 @@
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
 
-class Dept extends VerySimpleModel {
+class Dept extends VerySimpleModel
+implements TemplateVariable {
 
     static $meta = array(
         'table' => DEPT_TABLE,
@@ -71,6 +72,26 @@ class Dept extends VerySimpleModel {
         return $this->getName();
     }
 
+    static function getVarScope() {
+        return array(
+            'name' => 'Department name',
+            'manager' => array(
+                'class' => 'Staff', 'desc' => 'Department manager',
+                'exclude' => 'dept',
+            ),
+            'members' => array(
+                'class' => 'UserList', 'desc' => 'Department members',
+            ),
+            'parent' => array(
+                'class' => 'Dept', 'desc' => 'Parent department',
+            ),
+            'sla' => array(
+                'class' => 'SLA', 'desc' => 'Service Level Agreement',
+            ),
+            'signature' => 'Department signature',
+        );
+    }
+
     function getId() {
         return $this->id;
     }
@@ -170,7 +191,7 @@ class Dept extends VerySimpleModel {
 
             $this->_members = $members->all();
         }
-        return $this->_members;
+        return new UserList($this->_members);
     }
 
     function getAvailableMembers() {
diff --git a/include/class.dynamic_forms.php b/include/class.dynamic_forms.php
index d97e4b348683f47c6a1f6c9ac71668d303b9261c..e569f83792ae45880ee428c63b961e40d63cedf8 100644
--- a/include/class.dynamic_forms.php
+++ b/include/class.dynamic_forms.php
@@ -1268,13 +1268,14 @@ class DynamicFormEntryAnswer extends VerySimpleModel {
     }
 
     function asVar() {
-        return (is_object($this->getValue()))
-            ? $this->getValue() : $this->toString();
+        return $this->getField()->asVar(
+            $this->get('value'), $this->get('value_id')
+        );
     }
 
     function getVar($tag) {
-        if (is_object($this->getValue()) && method_exists($this->getValue(), 'getVar'))
-            return $this->getValue()->getVar($tag);
+        if (is_object($var = $this->asVar()) && method_exists($var, 'getVar'))
+            return $var->getVar($tag);
     }
 
     function __toString() {
@@ -1383,6 +1384,15 @@ class SelectionField extends FormField {
         return $value;
     }
 
+    function asVar($value, $id=false) {
+        $values = $this->to_php($value, $id);
+        if (is_array($values)) {
+            return new PlaceholderList($this->getList()->getAllItems()
+                ->filter(array('id__in' => array_keys($values)))
+            );
+        }
+    }
+
     function hasSubFields() {
         return $this->getList()->getForm();
     }
diff --git a/include/class.filter_action.php b/include/class.filter_action.php
index 531b997e3008354a0a1bc84f25d62121f1bdf50c..7de00efe9e82d79e974d3bd2411c60161920baf6 100644
--- a/include/class.filter_action.php
+++ b/include/class.filter_action.php
@@ -453,15 +453,42 @@ class FA_SendEmail extends TriggerAction {
         if (!$config['from'] || !($mailer = Email::lookup($config['from'])))
             $mailer = new Mailer();
 
-        // Honor %{user} variable
-        $to = $config['recipients'];
+        // Allow %{user} in the To: line
         $replacer = new VariableReplacer();
         $replacer->assign(array(
-            'user' => sprintf('%s <%s>', $ticket['name'], $ticket['email'])
+            'user' => sprintf('"%s" <%s>', $ticket['name'], $ticket['email'])
         ));
-        $to = $replacer->replaceVars($to);
+        $to = $replacer->replaceVars($config['recipients']);
+
+        require_once PEAR_DIR . 'Mail/RFC822.php';
+        require_once PEAR_DIR . 'PEAR.php';
+
+        if (!($mails = Mail_RFC822::parseAddressList($to)) || PEAR::isError($mails))
+            return false;
+
+        // Allow %{recipient} in the body
+        foreach ($mails as $R) {
+            $recipient = sprintf('%s <%s@%s>', $R->personal, $R->mailbox, $R->host);
+            $replacer->assign(array(
+                'recipient' => new EmailAddress($recipient),
+            ));
+            $I = $replacer->replaceVars($info);
+            $mailer->send($recipient, $I['subject'], $I['message']);
+        }
+
+    }
 
-        $mailer->send($to, $info['subject'], $info['message']);
+    static function getVarScope() {
+        $context = array(
+            'ticket' => array(
+                'class' => 'FA_SendEmail_TicketInfo', 'desc' => __('Ticket'),
+            ),
+            'user' => __('Ticket Submitter'),
+            'recipient' => array(
+                'class' => 'EmailAddress', 'desc' => __('Recipient'),
+            ),
+        ) + osTicket::getVarScope();
+        return VariableReplacer::compileScope($context);
     }
 
     function getConfigurationOptions() {
@@ -504,6 +531,7 @@ class FA_SendEmail extends TriggerAction {
                 'configuration' => array(
                     'placeholder' => __('Message'),
                     'html' => true,
+                    'context' => 'fa:send_email',
                 ),
             )),
             'from' => new ChoiceField(array(
@@ -515,3 +543,12 @@ class FA_SendEmail extends TriggerAction {
     }
 }
 FilterAction::register('FA_SendEmail', /* @trans */ 'Communication');
+
+class FA_SendEmail_TicketInfo {
+    static function getVarScope() {
+        return array(
+            'message' => __('Message from the EndUser'),
+            'source' => __('Source'),
+        );
+    }
+}
diff --git a/include/class.format.php b/include/class.format.php
index 83eb012168a119a405549be8e9872240f4808f17..52b2bcff56f6c1612b9b0dd100c30c54ec4af3dd 100644
--- a/include/class.format.php
+++ b/include/class.format.php
@@ -438,7 +438,7 @@ class Format {
     }
 
     function __formatDate($timestamp, $format, $fromDb, $dayType, $timeType,
-            $strftimeFallback, $timezone) {
+            $strftimeFallback, $timezone, $user=false) {
         global $cfg;
 
         if ($timestamp && $fromDb) {
@@ -446,7 +446,7 @@ class Format {
         }
         if (class_exists('IntlDateFormatter')) {
             $formatter = new IntlDateFormatter(
-                Internationalization::getCurrentLocale(),
+                Internationalization::getCurrentLocale($user),
                 $dayType,
                 $timeType,
                 $timezone,
@@ -492,40 +492,40 @@ class Format {
         return strtotime($date);
     }
 
-    function time($timestamp, $fromDb=true, $format=false, $timezone=false) {
+    function time($timestamp, $fromDb=true, $format=false, $timezone=false, $user=false) {
         global $cfg;
 
         return self::__formatDate($timestamp,
             $format ?: $cfg->getTimeFormat(), $fromDb,
             IDF_NONE, IDF_SHORT,
-            '%x', $timezone ?: $cfg->getTimezone());
+            '%x', $timezone ?: $cfg->getTimezone(), $user);
     }
 
-    function date($timestamp, $fromDb=true, $format=false, $timezone=false) {
+    function date($timestamp, $fromDb=true, $format=false, $timezone=false, $user=false) {
         global $cfg;
 
         return self::__formatDate($timestamp,
             $format ?: $cfg->getDateFormat(), $fromDb,
             IDF_SHORT, IDF_NONE,
-            '%X', $timezone ?: $cfg->getTimezone());
+            '%X', $timezone ?: $cfg->getTimezone(), $user);
     }
 
-    function datetime($timestamp, $fromDb=true, $timezone=false) {
+    function datetime($timestamp, $fromDb=true, $timezone=false, $user=false) {
         global $cfg;
 
         return self::__formatDate($timestamp,
                 $cfg->getDateTimeFormat(), $fromDb,
                 IDF_SHORT, IDF_SHORT,
-                '%X %x', $timezone ?: $cfg->getTimezone());
+                '%X %x', $timezone ?: $cfg->getTimezone(), $user);
     }
 
-    function daydatetime($timestamp, $fromDb=true, $timezone=false) {
+    function daydatetime($timestamp, $fromDb=true, $timezone=false, $user=false) {
         global $cfg;
 
         return self::__formatDate($timestamp,
                 $cfg->getDayDateTimeFormat(), $fromDb,
                 IDF_FULL, IDF_SHORT,
-                '%X %x', $timezone ?: $cfg->getTimezone());
+                '%X %x', $timezone ?: $cfg->getTimezone(), $user);
     }
 
     function getStrftimeFormat($format) {
@@ -710,6 +710,84 @@ class Format {
         }
         return $text;
     }
+
+    function relativeTime($to, $from=false) {
+        $timestamp = $to ?: Misc::gmtime();
+        if (gettype($timestamp) === 'string')
+            $timestamp = strtotime($timestamp);
+        $from = $from ?: Misc::gmtime();
+        if (gettype($timestamp) === 'string')
+            $from = strtotime($from);
+        $timeDiff = $from - $timestamp;
+        $absTimeDiff = abs($timeDiff);
+
+        // within 2 seconds
+        if ($absTimeDiff <= 2) {
+          return $timeDiff >= 0 ? __('just now') : __('now');
+        }
+
+        // within a minute
+        if ($absTimeDiff < 60) {
+          return sprintf($timeDiff >= 0 ? __('%d seconds ago') : __('in %d seconds'), $absTimeDiff);
+        }
+
+        // within 2 minutes
+        if ($absTimeDiff < 120) {
+          return sprintf($timeDiff >= 0 ? __('about a minute ago') : __('in about a minute'));
+        }
+
+        // within an hour
+        if ($absTimeDiff < 3600) {
+          return sprintf($timeDiff >= 0 ? __('%d minutes ago') : __('in %d minutes'), $absTimeDiff / 60);
+        }
+
+        // within 2 hours
+        if ($absTimeDiff < 7200) {
+          return ($timeDiff >= 0 ? __('about an hour ago') : __('in about an hour'));
+        }
+
+        // within 24 hours
+        if ($absTimeDiff < 86400) {
+          return sprintf($timeDiff >= 0 ? __('%d hours ago') : __('in %d hours'), $absTimeDiff / 3600);
+        }
+
+        // within 2 days
+        $days2 = 2 * 86400;
+        if ($absTimeDiff < $days2) {
+            // XXX: yesterday / tomorrow?
+          return $absTimeDiff >= 0 ? __('1 day ago') : __('in 1 day');
+        }
+
+        // within 29 days
+        $days29 = 29 * 86400;
+        if ($absTimeDiff < $days29) {
+          return sprintf($timeDiff >= 0 ? __('%d days ago') : __('in %d days'), $absTimeDiff / 86400);
+        }
+
+        // within 60 days
+        $days60 = 60 * 86400;
+        if ($absTimeDiff < $days60) {
+          return ($timeDiff >= 0 ? __('about a month ago') : __('in about a month'));
+        }
+
+        $currTimeYears = date('Y', $from);
+        $timestampYears = date('Y', $timestamp);
+        $currTimeMonths = $currTimeYears * 12 + date('n', $from);
+        $timestampMonths = $timestampYears * 12 + date('n', $timestamp);
+
+        // within a year
+        $monthDiff = $currTimeMonths - $timestampMonths;
+        if ($monthDiff < 12 && $monthDiff > -12) {
+          return sprintf($monthDiff >= 0 ? __('%d months ago') : __('in %d months'), abs($monthDiff));
+        }
+
+        $yearDiff = $currTimeYears - $timestampYears;
+        if ($yearDiff < 2 && $yearDiff > -2) {
+          return $yearDiff >= 0 ? __('a year ago') : __('in a year');
+        }
+
+        return sprintf($yearDiff >= 0 ? __('%d years ago') : __('in %d years'), abs($yearDiff));
+    }
 }
 
 if (!class_exists('IntlDateFormatter')) {
@@ -722,4 +800,103 @@ else {
     define('IDF_SHORT', IntlDateFormatter::SHORT);
     define('IDF_FULL', IntlDateFormatter::FULL);
 }
+
+class FormattedLocalDate
+implements TemplateVariable {
+    var $date;
+    var $timezone;
+    var $fromdb;
+
+    function __construct($date, $timezone=false, $user=false, $fromdb=true) {
+        $this->date = $date;
+        $this->timezone = $timezone;
+        $this->user = $user;
+        $this->fromdb = $fromdb;
+    }
+
+    function asVar() {
+        return $this->getVar('long');
+    }
+
+    function __toString() {
+        return $this->asVar();
+    }
+
+    function getVar($what) {
+        global $cfg;
+
+        if (method_exists($this, 'get' . ucfirst($what)))
+            return call_user_func(array($this, 'get'.ucfirst($what)));
+
+        // TODO: Rebase date format so that locale is discovered HERE.
+
+        switch ($what) {
+        case 'short':
+            return Format::date($this->date, $this->fromdb, false, $this->timezone, $this->user);
+        case 'long':
+            return Format::datetime($this->date, $this->fromdb, $this->timezone, $this->user);
+        case 'time':
+            return Format::time($this->date, $this->fromdb, false, $this->timezone, $this->user);
+        case 'full':
+            return Format::daydatetime($this->date, $this->fromdb, $this->timezone, $this->user);
+        }
+    }
+
+    static function getVarScope() {
+        return array(
+            'full' => 'Expanded date, e.g. day, month dd, yyyy',
+            'long' => 'Date and time, e.g. d/m/yyyy hh:mm',
+            'short' => 'Date only, e.g. d/m/yyyy',
+            'time' => 'Time only, e.g. hh:mm',
+        );
+    }
+}
+
+class FormattedDate
+extends FormattedLocalDate {
+    function asVar() {
+        return $this->getVar('system')->asVar();
+    }
+
+    function __toString() {
+        global $cfg;
+        return (string) new FormattedLocalDate($this->date, $cfg->getTimezone(), false, $this->fromdb);
+    }
+
+    function getVar($what) {
+        global $cfg;
+
+        if ($rv = parent::getVar($what))
+            return $rv;
+
+        switch ($what) {
+        case 'system':
+            return new FormattedLocalDate($this->date, $cfg->getDefaultTimezone());
+        }
+    }
+
+    function getHumanize() {
+        return Format::relativeTime(Misc::db2gmtime($this->date));
+    }
+
+    function getUser($context) {
+        global $cfg;
+
+        // Fetch $recipient from the context and find that user's time zone
+        if ($recipient = $context->getObj('recipient')) {
+            $tz = $recipient->getTimezone() ?: $cfg->getDefaultTimezone();
+            return new FormattedLocalDate($this->date, $tz, $recipient);
+        }
+    }
+
+    static function getVarScope() {
+        return parent::getVarScope() + array(
+            'humanize' => 'Humanized time, e.g. about an hour ago',
+            'user' => array(
+                'class' => 'FormattedLocalDate', 'desc' => "Localize to recipient's time zone and locale"),
+            'system' => array(
+                'class' => 'FormattedLocalDate', 'desc' => 'Localize to system default time zone'),
+        );
+    }
+}
 ?>
diff --git a/include/class.forms.php b/include/class.forms.php
index 0733afd4ea00500d5dcf55ba64609c1ebd81bc10..59b2d7251b23155e2c9dad61465a88c006415e44 100644
--- a/include/class.forms.php
+++ b/include/class.forms.php
@@ -518,6 +518,24 @@ class FormField {
         return $this->toString($value);
     }
 
+    /**
+     * Fetch a value suitable for embedding the value of this field in an
+     * email template. Reference implementation uses ::to_php();
+     */
+    function asVar($value, $id=false) {
+        return $this->to_php($value, $id);
+    }
+
+    /**
+     * Fetch the var type used with the email templating system's typeahead
+     * feature. This helps with variable expansion if supported by this
+     * field's ::asVar() method. This method should return a valid classname
+     * which implements the `TemplateVariable` interface.
+     */
+    function asVarType() {
+        return false;
+    }
+
     /**
      * Convert the field data to something matchable by filtering. The
      * primary use of this is for ticket filtering.
@@ -1297,6 +1315,14 @@ class DatetimeField extends FormField {
             return (int) $value;
     }
 
+    function asVar($value, $id=false) {
+        if (!$value) return null;
+        return new FormattedDate((int) $value, 'UTC', false, false);
+    }
+    function asVarType() {
+        return 'FormattedDate';
+    }
+
     function toString($value) {
         global $cfg;
         $config = $this->getConfiguration();
@@ -2162,6 +2188,45 @@ class FileUploadField extends FormField {
             $this->attachments->deleteAll();
         }
     }
+
+    function asVar($value, $id=false) {
+        return new FileFieldAttachments($this->getFiles());
+    }
+    function asVarType() {
+        return 'FileFieldAttachments';
+    }
+}
+
+class FileFieldAttachments {
+    var $files;
+
+    function __construct($files) {
+        $this->files = $files;
+    }
+
+    function __toString() {
+        $files = array();
+        foreach ($this->files as $f) {
+            $files[] = $f->file->name;
+        }
+        return implode(', ', $files);
+    }
+
+    function getVar($tag) {
+        switch ($tag) {
+        case 'names':
+            return $this->__toString();
+        case 'files':
+            throw new OOBContent(OOBContent::FILES, $this->files->all());
+        }
+    }
+
+    static function getVarScope() {
+        return array(
+            'names' => __('List of file names'),
+            'files' => __('Attached files'),
+        );
+    }
 }
 
 class InlineFormData extends ArrayObject {
@@ -2394,6 +2459,7 @@ class TextareaWidget extends Widget {
     function render($options=array()) {
         $config = $this->field->getConfiguration();
         $class = $cols = $rows = $maxlength = "";
+        $attrs = array();
         if (isset($config['rows']))
             $rows = "rows=\"{$config['rows']}\"";
         if (isset($config['cols']))
@@ -2406,9 +2472,12 @@ class TextareaWidget extends Widget {
             $class = sprintf('class="%s"', implode(' ', $class));
             $this->value = Format::viewableImages($this->value);
         }
+        if (isset($config['context']))
+            $attrs['data-root-context'] = '"'.$config['context'].'"';
         ?>
         <span style="display:inline-block;width:100%">
         <textarea <?php echo $rows." ".$cols." ".$maxlength." ".$class
+                .' '.Format::array_implode('=', ' ', $attrs)
                 .' placeholder="'.$config['placeholder'].'"'; ?>
             id="<?php echo $this->id; ?>"
             name="<?php echo $this->name; ?>"><?php
diff --git a/include/class.i18n.php b/include/class.i18n.php
index e0fcd9e11b9d864f66326bd9735abb244dfcd77a..c6e4bfc61b50973d19a3a2ad38d140178c2ef49f 100644
--- a/include/class.i18n.php
+++ b/include/class.i18n.php
@@ -379,14 +379,17 @@ class Internationalization {
         return self::getDefaultLanguage();
     }
 
-    static function getCurrentLocale() {
+    static function getCurrentLocale($user=false) {
         global $thisstaff, $cfg;
 
+        if ($user) {
+            return self::getCurrentLanguage($user);
+        }
         // FIXME: Move this majic elsewhere - see upgrade bug note in
         // class.staff.php
         if ($thisstaff) {
             return $thisstaff->getLocale()
-                ?: self::getCurrentLanguage();
+                ?: self::getCurrentLanguage($thisstaff);
         }
 
         if (!($locale = $cfg->getDefaultLocale()))
diff --git a/include/class.list.php b/include/class.list.php
index 12d2a85565a4062c4f8c24aca91ada5378bfd404..e873f4ee9a37d683422cd5e32cb3de19baaba3f5 100644
--- a/include/class.list.php
+++ b/include/class.list.php
@@ -14,8 +14,8 @@
 
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
-
 require_once(INCLUDE_DIR .'class.dynamic_forms.php');
+require_once(INCLUDE_DIR .'class.variable.php');
 
 /**
  * Interface for Custom Lists
@@ -746,7 +746,7 @@ class DynamicListItem extends VerySimpleModel implements CustomListItem {
         $name = mb_strtolower($name);
         foreach ($this->getConfigurationForm()->getFields() as $field) {
             if (mb_strtolower($field->get('name')) == $name)
-                return $config[$field->get('id')];
+                return $field->asVar($config[$field->get('id')]);
         }
     }
 
@@ -948,7 +948,9 @@ class TicketStatusList extends CustomListHandler {
     }
 }
 
-class TicketStatus  extends VerySimpleModel implements CustomListItem {
+class TicketStatus
+extends VerySimpleModel
+implements CustomListItem, TemplateVariable {
 
     static $meta = array(
         'table' => TICKET_STATUS_TABLE,
@@ -1204,6 +1206,15 @@ class TicketStatus  extends VerySimpleModel implements CustomListItem {
         return $T != $tag ? $T : $default;
     }
 
+    // TemplateVariable interface
+    static function getVarScope() {
+        $base = array(
+            'name' => __('Status label'),
+            'state' => __('State name (e.g. open or closed)'),
+        );
+        return $base;
+    }
+
     function getConfiguration() {
 
         if (!$this->_settings) {
diff --git a/include/class.mailer.php b/include/class.mailer.php
index 0abb652469fa8d6491dcd5efa1f37ac5bc139664..412428eccdf11d370951141fc91e3cd358ab5386 100644
--- a/include/class.mailer.php
+++ b/include/class.mailer.php
@@ -386,6 +386,17 @@ class Mailer {
         }
         $mime = new Mail_mime($eol);
 
+        // Add in extra attachments, if any from template variables
+        if ($message instanceof TextWithExtras
+            && ($files = $message->getFiles())
+        ) {
+            foreach ($files as $F) {
+                $file = $F->getFile();
+                $mime->addAttachment($file->getData(),
+                    $file->getType(), $file->getName(), false);
+            }
+        }
+
         // If the message is not explicitly declared to be a text message,
         // then assume that it needs html processing to create a valid text
         // body
diff --git a/include/class.organization.php b/include/class.organization.php
index ffd25216f52026e82a41d0f4a9b105bd82f17a5c..cc011495fa3e13114ffd8af3e2863946ca86d3f8 100644
--- a/include/class.organization.php
+++ b/include/class.organization.php
@@ -147,7 +147,8 @@ class OrganizationCdata extends VerySimpleModel {
 }
 
 
-class Organization extends OrganizationModel {
+class Organization extends OrganizationModel
+implements TemplateVariable {
     var $_entries;
     var $_forms;
 
@@ -299,6 +300,28 @@ class Organization extends OrganizationModel {
         foreach ($this->getDynamicData() as $e)
             if ($a = $e->getAnswer($tag))
                 return $a;
+
+        switch ($tag) {
+        case 'members':
+            return new UserList($this->users);
+        case 'manager':
+            return $this->getAccountManager();
+        case 'contacts':
+            return new UserList($this->users->filter(array(
+                'flags__hasbit' => User::PRIMARY_ORG_CONTACT
+            )));
+        }
+    }
+
+    static function getVarScope() {
+        $base = array(
+            'contacts' => array('class' => 'UserList', 'desc' => __('Primary Contacts')),
+            'manager' => __('Account Manager'),
+            'members' => array('class' => 'UserList', 'desc' => __('Organization Members')),
+            'name' => __('Name'),
+        );
+        $extra = VariableReplacer::compileFormScope(OrganizationForm::getInstance());
+        return $base + $extra;
     }
 
     function update($vars, &$errors) {
diff --git a/include/class.osticket.php b/include/class.osticket.php
index 15500c232ddbc3920fb0c5b73848d4c9e6ebebbc..75a247c1072a7cf3143c8929a4ef46e13b228d1b 100644
--- a/include/class.osticket.php
+++ b/include/class.osticket.php
@@ -145,6 +145,13 @@ class osTicket {
         return $replacer->replaceVars($input);
     }
 
+    static function getVarScope() {
+        return array(
+            'url' => __("osTicket's base url (FQDN)"),
+            'company' => array('class' => 'Company', 'desc' => __('Company Information')),
+        );
+    }
+
     function addExtraHeader($header, $pjax_script=false) {
         $this->headers[md5($header)] = $header;
         $this->pjax_extra[md5($header)] = $pjax_script;
diff --git a/include/class.page.php b/include/class.page.php
index 1aaa8ce3d8c8e0f934bd95e15f402e58fd74763b..a49a7b263d12632ee662aba4c1288c2bcf4616b9 100644
--- a/include/class.page.php
+++ b/include/class.page.php
@@ -341,5 +341,34 @@ class Page extends VerySimpleModel {
         }
         return true;
     }
+
+    static function getContext($type) {
+        $context = array(
+        'thank-you' => array('ticket'),
+        'registration-staff' => array(
+            // 'token' => __('Special authentication token'),
+            'staff' => array('class' => 'Staff', 'desc' => __('Message recipient')),
+            'recipient' => array('class' => 'Staff', 'desc' => __('Message recipient')),
+            'link',
+        ),
+        'pwreset-staff' => array(
+            'staff' => array('class' => 'Staff', 'desc' => __('Message recipient')),
+            'recipient' => array('class' => 'Staff', 'desc' => __('Message recipient')),
+            'link',
+        ),
+        'registration-client' => array(
+            // 'token' => __('Special authentication token'),
+            'recipient' => array('class' => 'User', 'desc' => __('Message recipient')),
+            'link', 'user',
+        ),
+        'pwreset-client' => array(
+            'recipient' => array('class' => 'User', 'desc' => __('Message recipient')),
+            'link', 'user',
+        ),
+        'access-link' => array('ticket', 'recipient'),
+        );
+
+        return $context[$type];
+    }
 }
 ?>
diff --git a/include/class.priority.php b/include/class.priority.php
index ac6976d29bb53517b41ee82d5dc4bf536e124bf0..8721ad81182028b942d7bc565ca71458f7bd3620 100644
--- a/include/class.priority.php
+++ b/include/class.priority.php
@@ -14,7 +14,8 @@
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
 
-class Priority extends VerySimpleModel {
+class Priority extends VerySimpleModel
+implements TemplateVariable {
 
     static $meta = array(
         'table' => PRIORITY_TABLE,
@@ -46,6 +47,14 @@ class Priority extends VerySimpleModel {
         return $this->ispublic;
     }
 
+    // TemplateVariable interface
+    function asVar() { return $this->getDesc(); }
+    static function getVarScope() {
+        return array(
+            'desc' => __('Priority Level'),
+        );
+    }
+
     function __toString() {
         return $this->getDesc();
     }
diff --git a/include/class.sla.php b/include/class.sla.php
index 8edb7a4360099b32506af1e563850e44887294a8..c1b4ead65df6f5d1ec796f7655e1d0a4322519f1 100644
--- a/include/class.sla.php
+++ b/include/class.sla.php
@@ -13,7 +13,8 @@
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
 
-class SLA extends VerySimpleModel {
+class SLA extends VerySimpleModel
+implements TemplateVariable {
 
     static $meta = array(
         'table' => SLA_TABLE,
@@ -95,6 +96,18 @@ class SLA extends VerySimpleModel {
         return $T != $tag ? $T : $default;
     }
 
+    // TemplateVariable interface
+    function asVar() {
+        return $this->getName();
+    }
+
+    static function getVarScope() {
+        return array(
+            'name' => __('SLA Plan'),
+            'graceperiod' => __("Grace Period (hrs)"),
+        );
+    }
+
     function update($vars, &$errors) {
 
         if (!$vars['grace_period'])
diff --git a/include/class.staff.php b/include/class.staff.php
index 4a13e1b382104d893ad1aae63376667ca53ce48c..9e093d7fcc93dcf349b1761f1bacd1a77ef2d839 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, EmailContact {
+implements AuthenticatedUser, EmailContact, TemplateVariable {
 
     static $meta = array(
         'table' => STAFF_TABLE,
@@ -78,6 +78,30 @@ implements AuthenticatedUser, EmailContact {
         return $this->__toString();
     }
 
+    static function getVarScope() {
+      return array(
+        'dept' => array('class' => 'Dept', 'desc' => __('Department')),
+        'email' => __('Email Address'),
+        'name' => array(
+          'class' => 'PersonsName', 'desc' => __('Agent name'),
+        ),
+        'mobile' => __('Mobile Number'),
+        'phone' => __('Phone Number'),
+        'signature' => __('Signature'),
+        'timezone' => "Agent's configured timezone",
+        'username' => 'Access username',
+      );
+    }
+
+    function getVar($tag) {
+        switch ($tag) {
+        case 'mobile':
+            return Format::phone($this->ht['mobile']);
+        case 'phone':
+            return Format::phone($this->ht['phone']);
+        }
+    }
+
     function getHashtable() {
         $base = $this->ht;
         $base['group'] = $base['group_id'];
diff --git a/include/class.team.php b/include/class.team.php
index 5a81170782a7225316d335b225ff3e0c2caeb622..04427634a0bcce591e1e460737f34cd445e5e56b 100644
--- a/include/class.team.php
+++ b/include/class.team.php
@@ -14,7 +14,8 @@
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
 
-class Team extends VerySimpleModel {
+class Team extends VerySimpleModel
+implements TemplateVariable {
 
     static $meta = array(
         'table' => TEAM_TABLE,
@@ -42,6 +43,18 @@ class Team extends VerySimpleModel {
         return (string) $this->getName();
     }
 
+    static function getVarScope() {
+        return array(
+            'name' => __('Team Name'),
+            'lead' => array(
+                'class' => 'Staff', 'desc' => __('Team Lead'),
+            ),
+            'members' => array(
+                'class' => 'UserList', 'desc' => __('Team Members'),
+            ),
+        );
+    }
+
     function getId() {
         return $this->team_id;
     }
@@ -62,7 +75,7 @@ class Team extends VerySimpleModel {
                 $this->_members[] = $m->staff;
         }
 
-        return $this->_members;
+        return new UserList($this->_members);
     }
 
     function hasMember($staff) {
diff --git a/include/class.template.php b/include/class.template.php
index 00a81c066c486a9fe9f95c1fea17b3bae57c683e..a48e5255d47dac70bea9234388b6ef81f35b6bf3 100644
--- a/include/class.template.php
+++ b/include/class.template.php
@@ -30,56 +30,108 @@ class EmailTemplateGroup {
         'ticket.autoresp'=>array(
             'group'=>'ticket.user',
             'name'=>/* @trans */ 'New Ticket Auto-response',
-            'desc'=>/* @trans */ 'Autoresponse sent to user, if enabled, on new ticket.'),
+            'desc'=>/* @trans */ 'Autoresponse sent to user, if enabled, on new ticket.',
+            'context' => array(
+                'ticket', 'signature', 'message', 'recipient'
+            ),
+        ),
         'ticket.autoreply'=>array(
             'group'=>'ticket.user',
             'name'=>/* @trans */ 'New Ticket Auto-reply',
-            'desc'=>/* @trans */ 'Canned Auto-reply sent to user on new ticket, based on filter matches. Overwrites "normal" auto-response.'),
+            'desc'=>/* @trans */ 'Canned Auto-reply sent to user on new ticket, based on filter matches. Overwrites "normal" auto-response.',
+            'context' => array(
+                'ticket', 'signature', 'response', 'recipient',
+            ),
+        ),
         'message.autoresp'=>array(
             'group'=>'ticket.user',
             'name'=>/* @trans */ 'New Message Auto-response',
-            'desc'=>/* @trans */ 'Confirmation sent to user when a new message is appended to an existing ticket.'),
+            'desc'=>/* @trans */ 'Confirmation sent to user when a new message is appended to an existing ticket.',
+            'context' => array(
+                'ticket', 'signature', 'recipient',
+            ),
+        ),
         'ticket.notice'=>array(
             'group'=>'ticket.user',
             'name'=>/* @trans */ 'New Ticket Notice',
-            'desc'=>/* @trans */ 'Notice sent to user, if enabled, on new ticket created by an agent on their behalf (e.g phone calls).'),
+            'desc'=>/* @trans */ 'Notice sent to user, if enabled, on new ticket created by an agent on their behalf (e.g phone calls).',
+            'context' => array(
+                'ticket', 'signature', 'recipient', 'staff', 'message',
+            ),
+        ),
         'ticket.overlimit'=>array(
             'group'=>'ticket.user',
             'name'=>/* @trans */ 'Over Limit Notice',
-            'desc'=>/* @trans */ 'A one-time notice sent, if enabled, when user has reached the maximum allowed open tickets.'),
+            'desc'=>/* @trans */ 'A one-time notice sent, if enabled, when user has reached the maximum allowed open tickets.',
+            'context' => array(
+                'ticket', 'signature',
+            ),
+        ),
         'ticket.reply'=>array(
             'group'=>'ticket.user',
             'name'=>/* @trans */ 'Response/Reply Template',
-            'desc'=>/* @trans */ 'Template used on ticket response/reply'),
+            'desc'=>/* @trans */ 'Template used on ticket response/reply',
+            'context' => array(
+                'ticket', 'signature', 'response', 'staff', 'poster', 'recipient',
+            ),
+        ),
         'ticket.activity.notice'=>array(
             'group'=>'ticket.user',
             'name'=>/* @trans */ 'New Activity Notice',
-            'desc'=>/* @trans */ 'Template used to notify collaborators on ticket activity (e.g CC on reply)'),
+            'desc'=>/* @trans */ 'Template used to notify collaborators on ticket activity (e.g CC on reply)',
+            'context' => array(
+                'ticket', 'signature', 'message', 'poster', 'recipient',
+            ),
+        ),
         'ticket.alert'=>array(
             'group'=>'ticket.staff',
             'name'=>/* @trans */ 'New Ticket Alert',
-            'desc'=>/* @trans */ 'Alert sent to agents, if enabled, on new ticket.'),
+            'desc'=>/* @trans */ 'Alert sent to agents, if enabled, on new ticket.',
+            'context' => array(
+                'ticket', 'recipient', 'message',
+            ),
+        ),
         'message.alert'=>array(
             'group'=>'ticket.staff',
             'name'=>/* @trans */ 'New Message Alert',
-            'desc'=>/* @trans */ 'Alert sent to agents, if enabled, when user replies to an existing ticket.'),
+            'desc'=>/* @trans */ 'Alert sent to agents, if enabled, when user replies to an existing ticket.',
+            'context' => array(
+                'ticket', 'recipient', 'message', 'poster',
+            ),
+        ),
         'note.alert'=>array(
             'group'=>'ticket.staff',
             'name'=>/* @trans */ 'Internal Activity Alert',
-            'desc'=>/* @trans */ 'Alert sent out to Agents when internal activity such as an internal note or an agent reply is appended to a ticket.'),
+            'desc'=>/* @trans */ 'Alert sent out to Agents when internal activity such as an internal note or an agent reply is appended to a ticket.',
+            'context' => array(
+                'ticket', 'recipient', 'note', 'comments', 'activity',
+            ),
+        ),
         'assigned.alert'=>array(
             'group'=>'ticket.staff',
             'name'=>/* @trans */ 'Ticket Assignment Alert',
-            'desc'=>/* @trans */ 'Alert sent to agents on ticket assignment.'),
+            'desc'=>/* @trans */ 'Alert sent to agents on ticket assignment.',
+            'context' => array(
+                'ticket', 'recipient', 'comments', 'assignee', 'assigner',
+            ),
+        ),
         'transfer.alert'=>array(
             'group'=>'ticket.staff',
             'name'=>/* @trans */ 'Ticket Transfer Alert',
-            'desc'=>/* @trans */ 'Alert sent to agents on ticket transfer.'),
+            'desc'=>/* @trans */ 'Alert sent to agents on ticket transfer.',
+            'context' => array(
+                'ticket', 'recipient', 'comments', 'staff',
+            ),
+        ),
         'ticket.overdue'=>array(
             'group'=>'ticket.staff',
             'name'=>/* @trans */ 'Overdue Ticket Alert',
-            'desc'=>/* @trans */ 'Alert sent to agents on stale or overdue tickets.'),
-        );
+            'desc'=>/* @trans */ 'Alert sent to agents on stale or overdue tickets.',
+            'context' => array(
+                'ticket', 'recipient', 'comments',
+            ),
+        ),
+    );
 
     function EmailTemplateGroup($id){
         $this->id=0;
@@ -157,7 +209,7 @@ class EmailTemplateGroup {
         return (db_query($sql) && db_affected_rows());
     }
 
-    function getTemplateDescription($name) {
+    static function getTemplateDescription($name) {
         return static::$all_names[$name];
     }
 
@@ -456,6 +508,22 @@ class EmailTemplate {
         return $this->getGroup()->getTemplateDescription($this->ht['code_name']);
     }
 
+    function getInvalidVariableUsage() {
+        $context = VariableReplacer::getContextForRoot($this->ht['code_name']);
+        $invalid = array();
+        foreach (array($this->getSubject(), $this->getBody()) as $B) {
+            $variables = array();
+            if (!preg_match_all('`%\{([^}]*)\}`', $B, $variables, PREG_SET_ORDER))
+                continue;
+            foreach ($variables as $V) {
+                if (!isset($context[$V[1]])) {
+                    $invalid[] = $V[0];
+                }
+            }
+        }
+        return $invalid;
+    }
+
     function update($vars, &$errors) {
 
         if(!$this->save($this->getId(),$vars,$errors))
diff --git a/include/class.thread.php b/include/class.thread.php
index 2b1d8bec7963775e21de4a6e4a67c2c0ede44286..4b3771e10c864cf0c611ea4d6b35d9a23b333fe9 100644
--- a/include/class.thread.php
+++ b/include/class.thread.php
@@ -396,7 +396,8 @@ class ThreadEntryEmailInfo extends VerySimpleModel {
     );
 }
 
-class ThreadEntry extends VerySimpleModel {
+class ThreadEntry extends VerySimpleModel
+implements TemplateVariable {
     static $meta = array(
         'table' => THREAD_ENTRY_TABLE,
         'pk' => array('id'),
@@ -821,17 +822,6 @@ class ThreadEntry extends VerySimpleModel {
         return $str;
     }
 
-    /* Returns file names with id as key */
-    function getFiles() {
-
-        $files = array();
-        foreach($this->attachments as $attachment)
-            $files[$attachment->file_id] = $attachment->file->name;
-
-        return $files;
-    }
-
-
     /* save email info
      * TODO: Refactor it to include outgoing emails on responses.
      */
@@ -873,27 +863,46 @@ class ThreadEntry extends VerySimpleModel {
         return (string) $this->getBody();
     }
 
+    // TemplateVariable interface
     function asVar() {
         return (string) $this->getBody()->display('email');
     }
 
     function getVar($tag) {
-        global $cfg;
-
-        if($tag && is_callable(array($this, 'get'.ucfirst($tag))))
+        if ($tag && is_callable(array($this, 'get'.ucfirst($tag))))
             return call_user_func(array($this, 'get'.ucfirst($tag)));
 
         switch(strtolower($tag)) {
             case 'create_date':
-                // XXX: Consider preferences of receiving user
-                return Format::datetime($this->getCreateDate(), true, 'UTC');
+                return new FormattedDate($this->getCreateDate());
             case 'update_date':
-                return Format::datetime($this->getUpdateDate(), true, 'UTC');
+                return new FormattedDate($this->getUpdateDate());
+            case 'files':
+                throw new OOBContent(OOBContent::FILES, $this->attachments->all());
         }
 
         return false;
     }
 
+    static function getVarScope() {
+        return array(
+          'files' => __('Attached files'),
+          'body' => __('Message body'),
+          'create_date' => array(
+              'class' => 'FormattedDate', 'desc' => __('Date created'),
+          ),
+          'ip_address' => __('IP address of remote user, for web submissions'),
+          'poster' => __('Submitter of the thread item'),
+          'staff' => array(
+            'class' => 'Staff', 'desc' => __('Agent posting the note or response'),
+          ),
+          'title' => __('Subject, if any'),
+          'user' => array(
+            'class' => 'User', 'desc' => __('User posting the message'),
+          ),
+        );
+    }
+
     /**
      * Parameters:
      * mailinfo (hash<String>) email header information. Must include keys
@@ -1528,6 +1537,12 @@ class MessageThreadEntry extends ThreadEntry {
 
         return parent::add($vars);
     }
+
+    static function getVarScope() {
+        $base = parent::getVarScope();
+        unset($base['staff']);
+        return $base;
+    }
 }
 
 /* thread entry of type response */
@@ -1568,6 +1583,12 @@ class ResponseThreadEntry extends ThreadEntry {
 
         return parent::add($vars);
     }
+
+    static function getVarScope() {
+        $base = parent::getVarScope();
+        unset($base['user']);
+        return $base;
+    }
 }
 
 /* Thread entry of type note (Internal Note) */
@@ -1598,10 +1619,17 @@ class NoteThreadEntry extends ThreadEntry {
 
         return parent::add($vars);
     }
+
+    static function getVarScope() {
+        $base = parent::getVarScope();
+        unset($base['user']);
+        return $base;
+    }
 }
 
 // Object specific thread utils.
-class ObjectThread extends Thread {
+class ObjectThread extends Thread
+implements TemplateVariable {
     private $_entries = array();
 
     static $types = array(
@@ -1729,6 +1757,13 @@ class ObjectThread extends Thread {
         }
     }
 
+    static function getVarScope() {
+      return array(
+        'original' => array('class' => 'MessageThreadEntry', 'desc' => __('Original Message')),
+        'lastmessage' => array('class' => 'MessageThreadEntry', 'desc' => __('Last Message')),
+      );
+    }
+
     static function lookup($criteria, $type=false) {
         if (!$type)
             return parent::lookup($criteria);
diff --git a/include/class.ticket.php b/include/class.ticket.php
index 2a14829dc83ed5d3658e7e15a7f750a04a6cc874..c275127847b51731ed838b4240d7d56d8bd594e1 100644
--- a/include/class.ticket.php
+++ b/include/class.ticket.php
@@ -212,7 +212,7 @@ TicketCData::$meta['table'] = TABLE_PREFIX . 'ticket__cdata';
 
 
 class Ticket
-implements RestrictedAccess, Threadable {
+implements RestrictedAccess, Threadable, TemplateVariable {
 
     var $id;
     var $number;
@@ -294,8 +294,8 @@ implements RestrictedAccess, Threadable {
         if (!$this->_answers) {
             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');
+                    $tag = mb_strtolower($answer->field->name)
+                        ?: 'field.' . $answer->field->id;
                         $this->_answers[$tag] = $answer;
                 }
             }
@@ -1797,7 +1797,7 @@ implements RestrictedAccess, Threadable {
 
     }
 
-    //ticket obj as variable = ticket number.
+    // TemplateVariable interface
     function asVar() {
        return $this->getNumber();
     }
@@ -1824,25 +1824,20 @@ implements RestrictedAccess, Threadable {
                 return sprintf('%s/scp/tickets.php?id=%d', $cfg->getBaseUrl(), $this->getId());
                 break;
             case 'create_date':
-                return Format::datetime($this->getCreateDate(), true, 'UTC');
+                return new FormattedDate($this->getCreateDate());
                 break;
              case 'due_date':
-                $duedate ='';
-                if($this->getEstDueDate())
-                    $duedate = Format::datetime($this->getEstDueDate(), true, 'UTC');
-
-                return $duedate;
+                if ($due = $this->getEstDueDate())
+                    return new FormattedDate($due);
                 break;
             case 'close_date':
-                $closedate ='';
-                if($this->isClosed())
-                    $closedate = Format::datetime($this->getCloseDate(), true, 'UTC');
-
-                return $closedate;
+                if ($this->isClosed())
+                    return new FormattedDate($this->getCloseDate());
                 break;
+            case 'last_update':
+                return new FormattedDate($upd);
             case 'user':
                 return $this->getOwner();
-                break;
             default:
                 if (isset($this->_answers[$tag]))
                     // The answer object is retrieved here which will
@@ -1854,6 +1849,63 @@ implements RestrictedAccess, Threadable {
         return false;
     }
 
+    static function getVarScope() {
+        $base = array(
+            'assigned' => __('Assigned agent and/or team'),
+            'close_date' => array(
+                'class' => 'FormattedDate', 'desc' => __('Date Closed'),
+            ),
+            'create_date' => array(
+                'class' => 'FormattedDate', 'desc' => __('Date created'),
+            ),
+            'dept' => array(
+                'class' => 'Dept', 'desc' => __('Department'),
+            ),
+            'due_date' => array(
+                'class' => 'FormattedDate', 'desc' => __('Due Date'),
+            ),
+            'email' => __('Default email address of ticket owner'),
+            'name' => array(
+                'class' => 'PersonsName', 'desc' => __('Name of ticket owner'),
+            ),
+            'number' => __('Ticket number'),
+            'phone' => __('Phone number of ticket owner'),
+            'priority' => array(
+                'class' => 'Priority', 'desc' => __('Priority'),
+            ),
+            'recipients' => array(
+                'class' => 'UserList', 'desc' => __('List of all recipient names'),
+            ),
+            'source' => __('Source'),
+            'status' => array(
+                'class' => 'TicketStatus', 'desc' => __('Status'),
+            ),
+            'staff' => array(
+                'class' => 'Staff', 'desc' => __('Assigned/closing agent'),
+            ),
+            'subject' => 'Subject',
+            'team' => array(
+                'class' => 'Team', 'desc' => __('Assigned/closing team'),
+            ),
+            'thread' => array(
+                'class' => 'TicketThread', 'desc' => __('Ticket Thread'),
+            ),
+            'topic' => array(
+                'class' => 'Topic', 'desc' => __('Help topic'),
+            ),
+            // XXX: Isn't lastreponse and lastmessage more useful
+            'last_update' => array(
+                'class' => 'FormattedDate', 'desc' => __('Time of last update'),
+            ),
+            'user' => array(
+                'class' => 'User', 'desc' => __('Ticket owner'),
+            ),
+        );
+
+        $extra = VariableReplacer::compileFormScope(TicketForm::getInstance());
+        return $base + $extra;
+    }
+
     //Replace base variables.
     function replaceVars($input, $vars = array()) {
         global $ost;
diff --git a/include/class.topic.php b/include/class.topic.php
index 9daeb7f07dd33b1e6493d4f85dc1b0b062ce834d..776f99e896905946fb5d973b2fc8b930e1104f5a 100644
--- a/include/class.topic.php
+++ b/include/class.topic.php
@@ -17,7 +17,8 @@
 require_once INCLUDE_DIR . 'class.sequence.php';
 require_once INCLUDE_DIR . 'class.filter.php';
 
-class Topic extends VerySimpleModel {
+class Topic extends VerySimpleModel
+implements TemplateVariable {
 
     static $meta = array(
         'table' => TOPIC_TABLE,
@@ -72,6 +73,22 @@ class Topic extends VerySimpleModel {
         return $this->getName();
     }
 
+    static function getVarScope() {
+        return array(
+            'dept' => array(
+                'class' => 'Dept', 'desc' => __('Department'),
+            ),
+            'fullname' => __('Help topic full path'),
+            'name' => __('Help topic'),
+            'parent' => array(
+                'class' => 'Topic', 'desc' => __('Parent'),
+            ),
+            'sla' => array(
+                'class' => 'SLA', 'desc' => __('Service Level Agreement'),
+            ),
+        );
+    }
+
     function getId() {
         return $this->topic_id;
     }
diff --git a/include/class.user.php b/include/class.user.php
index 7cb3cf2dfb9ef1855e56c6269a1f125831c3ae4b..960588dae4853e7a8f1952b7c4ac4d3885450104 100644
--- a/include/class.user.php
+++ b/include/class.user.php
@@ -17,6 +17,7 @@
 require_once INCLUDE_DIR . 'class.orm.php';
 require_once INCLUDE_DIR . 'class.util.php';
 require_once INCLUDE_DIR . 'class.organization.php';
+require_once INCLUDE_DIR . 'class.variable.php';
 
 class UserEmailModel extends VerySimpleModel {
     static $meta = array(
@@ -188,7 +189,8 @@ class UserCdata extends VerySimpleModel {
     }
 }
 
-class User extends UserModel {
+class User extends UserModel
+implements TemplateVariable {
 
     var $_entries;
     var $_forms;
@@ -260,7 +262,7 @@ class User extends UserModel {
     }
 
     function getEmail() {
-        return $this->default_email->address;
+        return new EmailAddress($this->default_email->address);
     }
 
     function getFullName() {
@@ -289,6 +291,15 @@ class User extends UserModel {
         return $this->created;
     }
 
+    function getTimezone() {
+        global $cfg;
+
+        if (($acct = $this->getAccount()) && ($tz = $acct->getTimezone())) {
+            return $tz;
+        }
+        return $cfg->getDefaultTimezone();
+    }
+
     function addForm($form, $sort=1, $data=null) {
         $entry = $form->instanciate($sort, $data);
         $entry->set('object_type', 'U');
@@ -331,6 +342,20 @@ class User extends UserModel {
                 return $a;
     }
 
+    static function getVarScope() {
+        $base = array(
+            'email' => array(
+                'class' => 'EmailAddress', 'desc' => __('Default email address')
+            ),
+            'name' => array(
+                'class' => 'PersonsName', 'desc' => 'User name, default format'
+            ),
+            'organization' => array('class' => 'Organization', 'desc' => __('Organization')),
+        );
+        $extra = VariableReplacer::compileFormScope(UserForm::getInstance());
+        return $base + $extra;
+    }
+
     function addDynamicData($data) {
         return $this->addForm(UserForm::objects()->one(), 1, $data);
     }
@@ -642,7 +667,49 @@ class User extends UserModel {
     }
 }
 
-class PersonsName {
+class EmailAddress
+implements TemplateVariable {
+    var $address;
+
+    function __construct($address) {
+        $this->address = $address;
+    }
+
+    function __toString() {
+        return $this->address;
+    }
+
+    function getVar($what) {
+        require_once PEAR_DIR . 'Mail/RFC822.php';
+        require_once PEAR_DIR . 'PEAR.php';
+        if (!($mails = Mail_RFC822::parseAddressList($this->address)) || PEAR::isError($mails))
+            return '';
+
+        if (!$list && count($mails) > 1)
+            return '';
+
+        $info = $mails[0];
+        switch ($what) {
+        case 'domain':
+            return $info->host;
+        case 'personal':
+            return trim($info->personal, '"');
+        case 'mailbox':
+            return $info->mailbox;
+        }
+    }
+
+    static function getVarScope() {
+        return array(
+            'domain' => __('Domain'),
+            'mailbox' => __('Mailbox'),
+            'personal' => __('Personal name'),
+        );
+    }
+}
+
+class PersonsName
+implements TemplateVariable {
     var $format;
     var $parts;
     var $name;
@@ -761,6 +828,16 @@ class PersonsName {
         return $this->__toString();
     }
 
+    static function getVarScope() {
+        $formats = array();
+        foreach (static::$formats as $name=>$info) {
+            if (in_array($name, array('original', 'complete')))
+                continue;
+            $formats[$name] = $info[0];
+        }
+        return $formats;
+    }
+
     function __toString() {
 
         @list(, $func) = static::$formats[$this->format];
@@ -976,6 +1053,10 @@ class UserAccountModel extends VerySimpleModel {
         return $lang;
     }
 
+    function getTimezone() {
+        return $this->timezone;
+    }
+
     function save($refetch=false) {
         // Serialize the extra column on demand
         if (isset($this->_extra)) {
@@ -1220,17 +1301,47 @@ class UserAccountStatus {
 /*
  *  Generic user list.
  */
-class UserList extends ListObject {
+class UserList extends ListObject
+implements TemplateVariable {
 
     function __toString() {
+        return $this->getNames();
+    }
 
+    function getNames() {
         $list = array();
         foreach($this->storage as $user) {
             if (is_object($user))
                 $list [] = $user->getName();
         }
+        return $list ? implode(', ', $list) : '';
+    }
 
+    function getFull() {
+        $list = array();
+        foreach($this->storage as $user) {
+            if (is_object($user))
+                $list[] = sprintf("%s <%s>", $user->getName(), $user->getEmail());
+        }
+
+        return $list ? implode(', ', $list) : '';
+    }
+
+    function getEmails() {
+        $list = array();
+        foreach($this->storage as $user) {
+            if (is_object($user))
+                $list[] = $user->getEmail();
+        }
         return $list ? implode(', ', $list) : '';
     }
+
+    static function getVarScope() {
+        return array(
+            'names' => __('List of names'),
+            'emails' => __('List of email addresses'),
+            'full' => __('List of names and email addresses'),
+        );
+    }
 }
 ?>
diff --git a/include/class.variable.php b/include/class.variable.php
index ffb850ec8f264dd2e15ada81e37e90b268c0ccec..3b9e8ce2497492f34436400072c603f6aaa12725 100644
--- a/include/class.variable.php
+++ b/include/class.variable.php
@@ -21,8 +21,9 @@ class VariableReplacer {
     var $start_delim;
     var $end_delim;
 
-    var $objects;
-    var $variables;
+    var $objects = array();
+    var $variables = array();
+    var $extras = array();
 
     var $errors;
 
@@ -30,9 +31,6 @@ class VariableReplacer {
 
         $this->start_delim = $start_delim;
         $this->end_delim = $end_delim;
-
-        $this->objects = array();
-        $this->variables = array();
     }
 
     function setError($error) {
@@ -65,14 +63,14 @@ class VariableReplacer {
 
         if (!$var) {
             if (method_exists($obj, 'asVar'))
-                return call_user_func(array($obj, 'asVar'));
+                return call_user_func(array($obj, 'asVar'), $this);
             elseif (method_exists($obj, '__toString'))
                 return (string) $obj;
         }
 
         list($v, $part) = explode('.', $var, 2);
         if ($v && is_callable(array($obj, 'get'.ucfirst($v)))) {
-            $rv = call_user_func(array($obj, 'get'.ucfirst($v)));
+            $rv = call_user_func(array($obj, 'get'.ucfirst($v)), $this);
             if(!$rv || !is_object($rv))
                 return $rv;
 
@@ -86,7 +84,7 @@ class VariableReplacer {
             return "";
 
         list($tag, $remainder) = explode('.', $var, 2);
-        if(($rv = call_user_func(array($obj, 'getVar'), $tag))===false)
+        if(($rv = call_user_func(array($obj, 'getVar'), $tag, $this))===false)
             return "";
 
         if(!is_object($rv))
@@ -97,13 +95,21 @@ class VariableReplacer {
 
     function replaceVars($input) {
 
+        // Preserve existing extras
+        if ($input instanceof TextWithExtras)
+            $this->extras = $input->extras;
+
         if($input && is_array($input))
             return array_map(array($this, 'replaceVars'), $input);
 
         if(!($vars=$this->_parse($input)))
             return $input;
 
-        return str_replace(array_keys($vars), array_values($vars), $input);
+        $text = str_replace(array_keys($vars), array_values($vars), $input);
+        if ($this->extras) {
+            return new TextWithExtras($text, $this->extras);
+        }
+        return $text;
     }
 
     function _resolveVar($var) {
@@ -113,9 +119,18 @@ class VariableReplacer {
             return $this->variables[$var];
 
         $parts = explode('.', $var, 2);
-        if($parts && ($obj=$this->getObj($parts[0])))
-            return $this->getVar($obj, $parts[1]);
-        elseif($parts[0] && @isset($this->variables[$parts[0]])) { //root override
+        try {
+            if ($parts && ($obj=$this->getObj($parts[0])))
+                return $this->getVar($obj, $parts[1]);
+        }
+        catch (OOBContent $content) {
+            $type = $content->getType();
+            $existing = @$this->extras[$type] ?: array();
+            $this->extras[$type] = array_merge($existing, $content->getContent());
+            return $content->asVar();
+        }
+
+        if ($parts[0] && @isset($this->variables[$parts[0]])) { //root override
             if (is_array($this->variables[$parts[0]])
                     && isset($this->variables[$parts[0]][$parts[1]]))
                 return $this->variables[$parts[0]][$parts[1]];
@@ -146,5 +161,220 @@ class VariableReplacer {
 
         return $vars;
     }
+
+    static function compileScope($scope, $recurse=5, $exclude=false) {
+        $items = array();
+        foreach ($scope as $name => $info) {
+            if ($exclude === $name)
+                continue;
+            if (isset($info['class']) && $recurse) {
+                $items[$name] = $info['desc'];
+                foreach (static::compileScope($info['class']::getVarScope(), $recurse-1,
+                    @$info['exclude'] ?: $name)
+                as $name2=>$desc) {
+                    $items["{$name}.{$name2}"] = $desc;
+                }
+            }
+            if (!is_array($info)) {
+                $items[$name] = $info;
+            }
+        }
+        return $items;
+    }
+
+    static function compileFormScope($form) {
+        $items = array();
+        foreach ($form->getFields() as $f) {
+            if (!($name = $f->get('name')))
+                continue;
+            if (!$f->isStorable() || !$f->hasData())
+                continue;
+
+            $desc = $f->getLocal('label');
+            if (($class = $f->asVarType()) && class_exists($class)) {
+                $desc = array('desc' => $desc, 'class' => $class);
+            }
+            $items[$name] = $desc;
+            foreach (VariableReplacer::compileFieldScope($f) as $name2=>$desc) {
+                $items["$name.$name2"] = $desc;
+            }
+        }
+        return $items;
+    }
+
+    static function compileFieldScope($field, $recurse=2, $exclude=false) {
+        $items = array();
+        if (!$field->hasSubFields())
+            return $items;
+
+        foreach ($field->getSubFields() as $f) {
+            if (!($name = $f->get('name')))
+                continue;
+            if ($exclude === $name)
+                continue;
+            $items[$name] = $f->getLabel();
+            if ($recurse) {
+                foreach (static::compileFieldScope($f, $recurse-1, $name)
+                as $name2=>$desc) {
+                    if (($class = $f->asVarType()) && class_exists($class)) {
+                        $desc = array('desc' => $desc, 'class' => $class);
+                    }
+                    $items["$name.$name2"] = $desc;
+                }
+            }
+        }
+        return $items;
+    }
+
+    static function getContextForRoot($root) {
+        switch ($root) {
+        case 'cannedresponse':
+            $roots = array('ticket');
+            break;
+
+        case 'fa:send_email':
+            // FIXME: Make this pluggable
+            require_once INCLUDE_DIR . 'class.filter_action.php';
+            return FA_SendEmail::getVarScope();
+
+        default:
+            if ($info = Page::getContext($root)) {
+                $roots = $info;
+                break;
+            }
+
+            // Get the context for an email template
+            if ($tpl_info = EmailTemplateGroup::getTemplateDescription($root))
+                $roots = $tpl_info['context'];
+        }
+
+        if (!$roots)
+            return false;
+
+        $contextTypes = array(
+            'activity' => __('Type of recent activity'),
+            'assignee' => array('class' => 'Staff', 'desc' => __('Assigned agent/team')),
+            'assigner' => array('class' => 'Staff', 'desc' => __('Agent performing the assignment')),
+            'comments' => __('Assign/transfer comments'),
+            'link' => __('Access link'),
+            'message' => array('class' => 'MessageThreadEntry', 'desc' => 'Message from the EndUser'),
+            'note' => array('class' => 'NoteThreadEntry', 'desc' => __('Internal note')),
+            'poster' => array('class' => 'User', 'desc' => 'EndUser or Agent originating the message'),
+            // XXX: This could be EndUser -or- Staff object
+            'recipient' => array('class' => 'TicketUser', 'desc' => 'Message recipient'),
+            'response' => array('class' => 'ResponseThreadEntry', 'desc' => __('Outgoing response')),
+            'signature' => 'Selected staff or department signature',
+            'staff' => array('class' => 'Staff', 'desc' => 'Agent originating the activity'),
+            'ticket' => array('class' => 'Ticket', 'desc' => 'The ticket'),
+            'user' => array('class' => 'User', 'desc' => __('Message recipient')),
+        );
+        $context = array();
+        foreach ($roots as $C=>$desc) {
+            // $desc may be either the root or the description array
+            if (is_array($desc))
+                $context[$C] = $desc;
+            else
+                $context[$desc] = $contextTypes[$desc];
+        }
+        $global = osTicket::getVarScope();
+        return self::compileScope($context + $global);
+    }
+}
+
+class PlaceholderList
+/* implements TemplateVariable */ {
+    var $items;
+
+    function __construct($items) {
+        $this->items = $items;
+    }
+
+    function asVar() {
+        $items = array();
+        foreach ($this->items as $I) {
+            if (method_exists($I, 'asVar')) {
+                $items[] = $I->asVar();
+            }
+            else {
+                $items[] = (string) $I;
+            }
+        }
+        return implode(',', $items);
+    }
+
+    function getVar($tag) {
+        $items = array();
+        foreach ($this->items as $I) {
+            if (is_object($I) && method_exists($I, 'get'.ucfirst($tag))) {
+                $items[] = call_user_func(array($I, 'get'.ucfirst($tag)));
+            }
+            elseif (method_exists($I, 'getVar')) {
+                $items[] = $I->getVar($tag);
+            }
+        }
+        if (count($items) == 1) {
+            return $items[0];
+        }
+        return new static(array_filter($items));
+    }
+}
+
+/**
+ * Exception used in the variable replacement process to indicate non text
+ * content (such as attachments)
+ */
+class OOBContent extends Exception {
+    var $type;
+    var $content;
+    var $text;
+
+    const FILES = 'files';
+
+    function __construct($type, $content, $asVar='') {
+        $this->type = $type;
+        $this->content = $content;
+        $this->text = $asVar;
+    }
+
+    function getType() { return $this->type; }
+    function getContent() { return $this->content; }
+    function asVar() { return $this->text; }
+}
+
+/**
+ * Simple wrapper to represent a rendered or partially rendered template
+ * with extra content such as attachments
+ */
+class TextWithExtras {
+    var $text = '';
+    var $extras;
+
+    function __construct($text, array $extras) {
+        $this->setText($text);
+        $this->extras = $extras;
+    }
+
+    function setText($text) {
+        try {
+            $this->text = (string) $text;
+        }
+        catch (Exception $e) {
+            throw new InvalidArgumentException('String type is required', 0, $e);
+        }
+    }
+
+    function __toString() {
+        return $this->text;
+    }
+
+    function getFiles() {
+        return $this->extras[OOBContent::FILES];
+    }
+}
+
+interface TemplateVariable {
+    // function asVar(); — not absolutely required
+    // function getVar($name); — not absolutely required
+    static function getVarScope();
 }
 ?>
diff --git a/include/staff/cannedresponse.inc.php b/include/staff/cannedresponse.inc.php
index 407c5ba89466beb080c2356f7aa8113546bce33b..8e407d25319dae991eaa3ba51e7e849e7abaf8c3 100644
--- a/include/staff/cannedresponse.inc.php
+++ b/include/staff/cannedresponse.inc.php
@@ -80,13 +80,13 @@ $info=Format::htmlchars(($errors && $_POST)?$_POST:$info);
                     <font class="error">*&nbsp;<?php echo $errors['response']; ?></font>
                     &nbsp;&nbsp;&nbsp;(<a class="tip" href="#ticket_variables"><?php echo __('Supported Variables'); ?></a>)
                     </div>
-                <textarea name="response" class="richtext draft draft-delete" cols="21" rows="12"
-                    style="width:98%;" class="richtext draft" <?php
+                <textarea name="response" cols="21" rows="12"
+                    data-root-context="cannedresponse"
+                    style="width:98%;" class="richtext draft draft-delete" <?php
     list($draft, $attrs) = Draft::getDraftAndDataAttrs('canned',
         is_object($canned) ? $canned->getId() : false, $info['response']);
     echo $attrs; ?>><?php echo $draft ?: $info['response'];
                 ?></textarea>
-                <br><br>
                 <div><h3><?php echo __('Canned Attachments'); ?> <?php echo __('(optional)'); ?>
                 &nbsp;<i class="help-tip icon-question-sign" href="#canned_attachments"></i></h3>
                 <div class="error"><?php echo $errors['files']; ?></div>
diff --git a/include/staff/page.inc.php b/include/staff/page.inc.php
index 6326500aba5bd1b6d545eb1f8eae59bb7645cbda..ea373533c886b1b9bd7d11f8cd651e461dca1072 100644
--- a/include/staff/page.inc.php
+++ b/include/staff/page.inc.php
@@ -145,6 +145,7 @@ if ($page && count($langs) > 1) { ?>
             lang="<?php echo $cfg->getPrimaryLanguage(); ?>">
         <textarea name="body" cols="21" rows="12" style="width:100%" class="richtext draft"
 <?php
+    if (!$info['type'] || $info['type'] == 'thank-you') echo 'data-root-context="thank-you"';
     list($draft, $attrs) = Draft::getDraftAndDataAttrs('page', $info['id'], $info['body']);
     echo $attrs; ?>><?php echo $draft ?: $info['body']; ?></textarea>
         </div>
@@ -156,6 +157,7 @@ if ($page && count($langs) > 1) { ?>
         <div id="translation-<?php echo $tag; ?>" class="tab_content"
             style="display:none;" lang="<?php echo $tag; ?>">
         <textarea name="trans[<?php echo $tag; ?>][body]" cols="21" rows="12"
+<?php if ($info['type'] == 'thank-you') echo 'data-root-context="thank-you"'; ?>
             style="width:100%" class="richtext draft"
 <?php
     list($draft, $attrs) = Draft::getDraftAndDataAttrs('page', $info['id'].'.'.$tag, $info['trans'][$tag]);
diff --git a/include/staff/templates/content-manage.tmpl.php b/include/staff/templates/content-manage.tmpl.php
index 28a016cb71163b256e32a819b8446b832766a60f..3fa4e8fcbd8c1554649c576d84f3ba74e74e4eca 100644
--- a/include/staff/templates/content-manage.tmpl.php
+++ b/include/staff/templates/content-manage.tmpl.php
@@ -30,9 +30,10 @@ if (count($langs) > 1) { ?>
         echo Format::htmlchars($info['title']); ?>" />
     <div style="margin-top: 5px">
     <div class="error"><?php echo $errors['body']; ?></div>
-    <textarea class="richtext no-bar" name="body"><?php
-    echo Format::htmlchars(Format::viewableImages($info['body']));
-?></textarea>
+    <textarea class="richtext no-bar" name="body"
+        data-root-context="<?php echo $content->getType();
+        ?>"><?php echo Format::htmlchars(Format::viewableImages($info['body']));
+        ?></textarea>
     </div>
     </div>
 
@@ -48,6 +49,7 @@ if (count($langs) > 1) { ?>
         placeholder="<?php echo __('Title'); ?>" />
     <div style="margin-top: 5px">
     <textarea class="richtext no-bar" data-direction=<?php echo $nfo['direction']; ?>
+        data-root-context="<?php echo $content->getType(); ?>"
         placeholder="<?php echo __('Message content'); ?>"
         name="trans[<?php echo $tag; ?>][body]"><?php
     echo Format::htmlchars(Format::viewableImages($trans['body']));
diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php
index bdc09745da094d2daef7cb944830c7a4a267e9e7..a0e843a0c47e46144ccc66caaf8911ddd758519f 100644
--- a/include/staff/ticket-view.inc.php
+++ b/include/staff/ticket-view.inc.php
@@ -187,7 +187,7 @@ if($ticket->isOverdue())
                 </tr>
                 <tr>
                     <th><?php echo __('Create Date');?>:</th>
-                    <td><?php echo Format::datetime($ticket->getCreateDate()); ?></td>
+                    <td><?php echo Format::relativeTime($ticket->getCreateDate()); ?></td>
                 </tr>
             </table>
         </td>
diff --git a/include/staff/tpl.inc.php b/include/staff/tpl.inc.php
index 37e29177e80d850efb54bbd6e8a0c79c891db421..70c9e90b44f6ae456df72c9816992d9a6e881624 100644
--- a/include/staff/tpl.inc.php
+++ b/include/staff/tpl.inc.php
@@ -99,6 +99,19 @@ $tpl=$msgtemplates[$selected];
 <?php } ?>
 </div>
 
+<?php
+$invalid = array();
+if ($template instanceof EmailTemplate) {
+    if ($invalid = $template->getInvalidVariableUsage()) {
+    $invalid = array_unique($invalid); ?>
+    <div class="warning-banner"><?php echo
+        __('Some variables may not be a valid for this context. Please check for spelling errors and correct usage for this template.') ?>
+    <br/>
+    <code><?php echo implode(', ', $invalid); ?></code>
+</div>
+<?php }
+} ?>
+
 <div style="padding-bottom:3px;" class="faded"><strong><?php echo __('Email Subject and Body'); ?>:</strong></div>
 <div id="toolbar"></div>
 <div id="save" style="padding-top:5px;">
@@ -108,6 +121,7 @@ $tpl=$msgtemplates[$selected];
     </div>
     <input type="hidden" name="draft_id" value=""/>
     <textarea name="body" cols="21" rows="16" style="width:98%;" wrap="soft"
+        data-root-context="<?php echo $selected; ?>"
         data-toolbar-external="#toolbar" class="richtext draft" <?php
     list($draft, $attrs) = Draft::getDraftAndDataAttrs('tpl.'.$selected, $tpl_id, $info['body']);
     echo $attrs; ?>><?php echo $draft ?: $info['body'];
diff --git a/js/redactor-osticket.js b/js/redactor-osticket.js
index 203c398944384fa5fb620e6cdf5652aa0d8d950e..de2a898253b7910d6c8f583c3023717df1c0e640 100644
--- a/js/redactor-osticket.js
+++ b/js/redactor-osticket.js
@@ -298,6 +298,9 @@ $(function() {
             options['plugins'].push('imagepaste');
             options.draftDelete = el.hasClass('draft-delete');
         }
+        if (true || 'scp') { // XXX: Add this to SCP only
+            options['plugins'].push('contexttypeahead');
+        }
         if (el.hasClass('fullscreen'))
             options['plugins'].push('fullscreen');
         if ($('#ticket_thread[data-thread-id]').length)
diff --git a/js/redactor-plugins.js b/js/redactor-plugins.js
index f41fb48e9cd82e81e93fcbedb1d19a9fe20f2b26..772573bab655b84260ba3b706921f0115a47e254 100644
--- a/js/redactor-plugins.js
+++ b/js/redactor-plugins.js
@@ -1606,3 +1606,191 @@ RedactorPlugins.imageannotate = function() {
     }
   };
 };
+
+RedactorPlugins.contexttypeahead = function() {
+  return {
+    typeahead: false,
+    context: false,
+    variables: false,
+
+    init: function() {
+      if (!this.$element.data('rootContext'))
+        return;
+
+      this.opts.keyupCallback = this.contexttypeahead.watch.bind(this);
+      this.opts.keydownCallback = this.contexttypeahead.watch.bind(this);
+      this.$editor.on('click', this.contexttypeahead.watch.bind(this));
+    },
+
+    watch: function(e) {
+      var current = this.selection.getCurrent(),
+          allText = this.$editor.text(),
+          offset = this.caret.getOffset(),
+          lhs = allText.substring(0, offset),
+          search = new RegExp(/%\{([^}]*)$/),
+          match;
+
+      if (!lhs) {
+        return !e.isDefaultPrevented();
+      }
+
+      if (e.which == 27 || !(match = search.exec(lhs)))
+        // No longer in a element — close typeahead
+        return this.contexttypeahead.destroy();
+
+      if (e.type == 'click')
+        return;
+
+      // Locate the position of the cursor and the number of characters back
+      // to the `%{` symbols
+      var sel         = this.selection.get(),
+          range       = this.sel.getRangeAt(0),
+          content     = current.textContent,
+          clientRects = range.getClientRects(),
+          position    = clientRects[0],
+          backText    = match[1],
+          parent      = this.selection.getParent() || this.$editor,
+          plugin      = this.contexttypeahead;
+
+      // Insert a hidden text input to receive the typed text and add a
+      // typeahead widget
+      if (!this.contexttypeahead.typeahead) {
+        this.contexttypeahead.typeahead = $('<input type="text">')
+          .css({position: 'absolute', visibility: 'hidden'})
+          .width(0).height(position.height - 4)
+          .appendTo(document.body)
+          .typeahead({
+            property: 'variable',
+            minLength: 0,
+            arrow: $('<span class="pull-right"><i class="icon-muted icon-chevron-right"></i></span>')
+                .css('padding', '0 0 0 6px'),
+            highlighter: function(variable, item) {
+              var base = $.fn.typeahead.Constructor.prototype.highlighter
+                    .call(this, variable),
+                  further = new RegExp(variable + '\\.'),
+                  extendable = Object.keys(plugin.variables).some(function(v) {
+                    return v.match(further);
+                  }),
+                  arrow = extendable ? this.options.arrow.clone() : '';
+
+              return $('<div/>').html(base).prepend(arrow).html()
+                + $('<span class="faded">')
+                  .text(' — ' + item.desc)
+                  .wrap('<div>').parent().html();
+            },
+            item: '<li><a href="#" style="display:block"></a></li>',
+            source: this.contexttypeahead.getContext.bind(this),
+            sorter: function(items) {
+              items.sort(
+                function(a,b) {return a.variable > b.variable ? 1 : -1;}
+              );
+              return items;
+            },
+            matcher: function(item) {
+              if (item.toLowerCase().indexOf(this.query.toLowerCase()) !== 0)
+                return false;
+
+              return (this.query.match(/\./g) || []).length == (item.match(/\./g) || []).length;
+            },
+            onselect: this.contexttypeahead.select.bind(this),
+            scroll: true,
+            items: 100
+          });
+      }
+
+      if (position) {
+        var width = plugin.textWidth(
+              backText,
+              this.selection.getParent() || $('<div class="redactor-editor">')
+            ),
+            pleft = $(parent).offset().left,
+            left = position.left - width;
+
+        if (left < pleft)
+            // This is a bug in chrome, but I'm not sure how to adjust it
+            left += pleft;
+
+        plugin.typeahead
+          .css({top: position.top + $(window).scrollTop(), left: left});
+      }
+
+      plugin.typeahead
+        .val(match[1])
+        .trigger(e);
+
+      return !e.isDefaultPrevented();
+    },
+
+    getContext: function(typeahead, query) {
+      var dfd, that=this.contexttypeahead,
+          root = this.$element.data('rootContext');
+      if (!this.contexttypeahead.context) {
+        dfd = $.Deferred();
+        $.ajax('ajax.php/content/context', {
+          data: {root: root},
+          success: function(json) {
+            var items = $.map(json, function(v,k) {
+              return {variable: k, desc: v};
+            });
+            that.variables = json;
+            dfd.resolve(items);
+          }
+        });
+        this.contexttypeahead.context = dfd;
+      }
+      // Only fetch the context once for this redactor box
+      this.contexttypeahead.context.then(function(items) {
+        typeahead.process(items);
+      });
+    },
+
+    textWidth: function(text, clone) {
+      var c = $(clone),
+          o = c.clone().text(text)
+            .css({'position': 'absolute', 'float': 'left', 'white-space': 'nowrap', 'visibility': 'hidden'})
+            .css({'font-family': c.css('font-family'), 'font-weight': c.css('font-weight'),
+              'font-size': c.css('font-size')})
+            .appendTo($('body')),
+          w = o.width();
+
+      o.remove();
+
+      return w;
+    },
+
+    destroy: function() {
+      if (this.contexttypeahead.typeahead) {
+        this.contexttypeahead.typeahead.typeahead('hide');
+        this.contexttypeahead.typeahead.remove();
+        this.contexttypeahead.typeahead = false;
+      }
+    },
+
+    select: function(item) {
+      var current = this.selection.getCurrent(),
+          sel     = this.selection.get(),
+          range   = this.sel.getRangeAt(0),
+          cursorAt = range.endOffset,
+          search  = new RegExp(/%\{([^}]*)(\}?)$/);
+
+      // FIXME: ENTER will end up here, but current will be empty
+
+      if (!current)
+        return;
+
+      // Set cursor at the end of the expanded text
+      var left = current.textContent.substring(0, cursorAt),
+          right = current.textContent.substring(cursorAt),
+          newLeft = left.replace(search, '%{' + item.variable + '}');
+
+      current.textContent = newLeft
+        // Drop the remaining part of a variable block, if any
+        + right.replace(/[^%}]*?[%}]/, '');
+
+      this.range.setStart(current, newLeft.length - 1);
+      this.range.setEnd(current, newLeft.length - 1);
+      this.selection.addRange();
+      return this.contexttypeahead.destroy();
+    }
+  };
+};
diff --git a/scp/ajax.php b/scp/ajax.php
index 7a6b95fcb3662333969698cfc58ff0527f82b5e7..f34bcfb930f912ea70e6b4592324924ec62fd4a7 100644
--- a/scp/ajax.php
+++ b/scp/ajax.php
@@ -41,6 +41,7 @@ $dispatcher = patterns('',
     )),
     url('^/content/', patterns('ajax.content.php:ContentAjaxAPI',
         url_get('^log/(?P<id>\d+)', 'log'),
+        url_get('^context$', 'context'),
         url_get('^ticket_variables', 'ticket_variables'),
         url_get('^signature/(?P<type>\w+)(?:/(?P<id>\d+))?$', 'getSignature'),
         url_get('^(?P<id>\d+)/(?:(?P<lang>\w+)/)?manage$', 'manageContent'),
diff --git a/scp/css/scp.css b/scp/css/scp.css
index 7817e76560c9610788831f445bd6caa6f4c743e5..279fb557b963772535531845d7744cf91e912bcb 100644
--- a/scp/css/scp.css
+++ b/scp/css/scp.css
@@ -56,7 +56,8 @@ div#header a {
 }
 
 .faded {
-    color:#666;
+    color: #666;
+    color: rgba(0,0,0,0.5);
 }
 .faded-more {
     color: #aaa;
diff --git a/scp/css/typeahead.css b/scp/css/typeahead.css
index 981923ab1f4c200b7121f5563171101dfea0a4d2..2e4e4d6ccd71853ecbf21973f302272476fe8dc3 100644
--- a/scp/css/typeahead.css
+++ b/scp/css/typeahead.css
@@ -7,7 +7,7 @@
   float: left;
   display: none;
   min-width: 160px;
-  padding: 4px 0;
+  padding: 4px 0 2px;
   margin: 0;
   list-style: none;
   background-color: #ffffff;
@@ -20,17 +20,24 @@
   border-radius: 0 0 5px 5px;
   -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
   -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
-  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 10px 15px -5px rgba(0, 0, 0, 0.5);
   -webkit-background-clip: padding-box;
   -moz-background-clip: padding;
   background-clip: padding-box;
   *border-right-width: 2px;
   *border-bottom-width: 2px;
+  opacity: 0.95;
 }
 .dropdown-menu.pull-right {
   right: 0;
   left: auto;
 }
+.dropdown-menu.scroll {
+  max-height: 180px;
+  height: auto;
+  overflow-y: auto;
+  padding: 0;
+}
 .dropdown-menu .divider {
   height: 1px;
   margin: 8px 1px;
@@ -42,7 +49,7 @@
 }
 .dropdown-menu a {
   display: block;
-  padding: 3px 15px;
+  padding: 4px 15px;
   clear: both;
   font-weight: normal;
   line-height: 18px;
@@ -56,3 +63,12 @@
   text-decoration: none;
   background-color: #0088cc;
 }
+.dropdown-menu li > a:hover .faded,
+.dropdown-menu .active > a .faded,
+.dropdown-menu .active > a:hover .faded {
+  color: rgba(255,255,255,0.6);
+}
+
+.dropdown-menu li + li {
+    border-top: 1px solid rgba(0,0,0,0.15);
+}
diff --git a/scp/js/bootstrap-typeahead.js b/scp/js/bootstrap-typeahead.js
index c274748d808b65d6bd52f3327cc23d42f1f5b563..6c8238ad132a24178d565420083d6ccd0cd27cff 100644
--- a/scp/js/bootstrap-typeahead.js
+++ b/scp/js/bootstrap-typeahead.js
@@ -28,6 +28,8 @@
     this.sorter = this.options.sorter || this.sorter
     this.highlighter = this.options.highlighter || this.highlighter
     this.$menu = $(this.options.menu).appendTo('body')
+    if (this.options.scroll)
+      this.$menu.addClass('scroll');
     this.source = this.options.source
     this.onselect = this.options.onselect
     this.strings = true
@@ -101,15 +103,15 @@
 
       this.query = this.$element.val()
 
-      if (!this.query) {
+      if (this.query.length < this.options.minLength) {
         return this.shown ? this.hide() : this
       }
-      
+
       items = $.grep(results, function (item) {
         if (!that.strings)
           item = item[that.options.property]
 
-        if (that.matcher(item)) 
+        if (that.matcher(item))
             return item
       })
 
@@ -146,6 +148,8 @@
     }
 
   , highlighter: function (item) {
+      if (!this.query)
+          return item;
       return item.replace(new RegExp('(' + this.query + ')', 'ig'), function ($1, match) {
         return '<strong>' + match + '</strong>'
       })
@@ -155,6 +159,7 @@
       var that = this
 
       items = $(items).map(function (i, item) {
+        var orig = item;
         i = $(that.options.item).attr('data-value', JSON.stringify(item))
         if (!that.strings) {
             if(item[that.options.render])
@@ -162,7 +167,7 @@
             else
                 item = item[that.options.property];
         }
-        i.find('a').html(that.highlighter(item))
+        i.find('a').html(that.highlighter(item, orig))
         return i[0]
       })
 
@@ -171,6 +176,16 @@
       return this
     }
 
+  , adjustScroll: function(next) {
+      var top = this.$menu.scrollTop(),
+        bottom = top + this.$menu.height(),
+        pos = next.position();
+      if (pos.top < 0)
+        this.$menu.scrollTop(top + pos.top - 10);
+      else if (next.height() + top + pos.top > bottom)
+        this.$menu.scrollTop(top + pos.top - this.$menu.height() + next.height() + 10);
+  }
+
   , next: function (event) {
       var active = this.$menu.find('.active').removeClass('active')
         , next = active.next()
@@ -180,6 +195,10 @@
       }
 
       next.addClass('active')
+
+      if (this.options.scroll) {
+        this.adjustScroll(next);
+      }
     }
 
   , prev: function (event) {
@@ -191,6 +210,10 @@
       }
 
       prev.addClass('active')
+
+      if (this.options.scroll) {
+        this.adjustScroll(prev);
+      }
     }
 
   , listen: function () {
@@ -283,6 +306,10 @@
       $(e.currentTarget).addClass('active')
     }
 
+  , visible: function() {
+      return this.shown;
+    }
+
   }
 
 
@@ -307,6 +334,8 @@
   , onselect: null
   , property: 'value'
   , render: 'info'
+  , minLength: 1
+  , scroll: false
   }
 
   $.fn.typeahead.Constructor = Typeahead