diff --git a/include/class.client.php b/include/class.client.php
index 5b819e2125857909a86ae27992b67b040cf23bbf..704b046c6b8384bef8eba09f7a2e275e2726fd69 100644
--- a/include/class.client.php
+++ b/include/class.client.php
@@ -55,6 +55,11 @@ implements EmailContact, ITicketUser, TemplateVariable {
 
     }
 
+    // Required for Internationalization::getCurrentLanguage() in templates
+    function getLanguage() {
+        return $this->user->getLanguage();
+    }
+
     static function getVarScope() {
         return array(
             'name' => array('class' => 'PersonsName', 'desc' => __('Full name')),
diff --git a/include/class.format.php b/include/class.format.php
index 83eb012168a119a405549be8e9872240f4808f17..59becb5d231ee103831dc1f6421b80ed8ef7a63d 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,101 @@ 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)));
+
+        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 d63b17129e439a20c114195f3fbc0e253ad6eb66..66eb26b5e38cfa9dcd61b3040adaaf3bfc621f98 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.
@@ -1293,6 +1311,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();
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.ticket.php b/include/class.ticket.php
index c13e85fbb3d6fd2d0193f7bd0f6deaedd1cd3882..98f29976495429535feb60b6d4f138ac925d34ef 100644
--- a/include/class.ticket.php
+++ b/include/class.ticket.php
@@ -1824,21 +1824,15 @@ implements RestrictedAccess, Threadable, TemplateVariable {
                 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 'user':
                 return $this->getOwner();
@@ -1857,12 +1851,18 @@ implements RestrictedAccess, Threadable, TemplateVariable {
     static function getVarScope() {
         $base = array(
             'assigned' => 'Assigned agent and/or team',
-            'close_date' => 'Date of ticket closure',
-            'create_date' => 'Ticket create date',
+            'close_date' => array(
+                'class' => 'FormattedDate', 'desc' => 'Date of ticket closure',
+            ),
+            'create_date' => array(
+                'class' => 'FormattedDate', 'desc' => 'Ticket create date',
+            ),
             'dept' => array(
                 'class' => 'Dept', 'desc' => 'Department',
             ),
-            'due_date' => 'Ticket due date',
+            'due_date' => array(
+                'class' => 'FormattedDate', 'desc' => 'Ticket due date',
+            ),
             'email' => 'Default email address of ticket owner',
             'name' => array(
                 'class' => 'PersonsName', 'desc' => __('Name of ticket owner'),
diff --git a/include/class.user.php b/include/class.user.php
index 5c9eee9f952dd3823dbd15de24d1c11ed9f50766..028832c7d743c767c6fd7e27fba0c0fa31822539 100644
--- a/include/class.user.php
+++ b/include/class.user.php
@@ -291,6 +291,15 @@ implements TemplateVariable {
         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');
@@ -1001,6 +1010,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)) {
diff --git a/include/class.variable.php b/include/class.variable.php
index 3186739ca4bc42dfc13a9712407d87d70c5a52bc..051f9f6a5290eb60135fe1f28d525ffe4c0bf697 100644
--- a/include/class.variable.php
+++ b/include/class.variable.php
@@ -65,14 +65,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 +86,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))
@@ -176,6 +176,9 @@ class VariableReplacer {
                 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;
@@ -198,6 +201,9 @@ class VariableReplacer {
             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;
                 }
             }
diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php
index e2cc27c5f3441b18d42b9c6b227b6ae0583158dc..f8b5d21a1fcb38ac9291e57495f233f328debe94 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>