Skip to content
Snippets Groups Projects
class.thread.php 58.4 KiB
Newer Older
<?php
/*********************************************************************
    class.thread.php

Peter Rotich's avatar
Peter Rotich committed
    Thread of things!
Peter Rotich's avatar
Peter Rotich committed
    XXX: Please DO NOT add any ticket related logic! use ticket class.

    Peter Rotich <peter@osticket.com>
    Copyright (c)  2006-2013 osTicket
    http://www.osticket.com

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
include_once(INCLUDE_DIR.'class.ticket.php');
include_once(INCLUDE_DIR.'class.draft.php');
include_once(INCLUDE_DIR.'class.role.php');
//Ticket thread.
class Thread extends VerySimpleModel {
    static $meta = array(
        'table' => THREAD_TABLE,
        'pk' => array('id'),
        'joins' => array(
            'ticket' => array(
                'constraint' => array(
                    'object_type' => "'T'",
                    'object_id' => 'TicketModel.ticket_id',
            'task' => array(
                'constraint' => array(
                    'object_type' => "'A'",
                    'object_id' => 'Task.id',
                ),
            ),
            'collaborators' => array(
                'reverse' => 'Collaborator.thread',
            ),
                'reverse' => 'ThreadEntry.thread',
Peter Rotich's avatar
Peter Rotich committed

    function getId() {
Peter Rotich's avatar
Peter Rotich committed
    function getObjectId() {
Peter Rotich's avatar
Peter Rotich committed
    function getObjectType() {
    function getObject() {

        if (!$this->_object)
            $this->_object = ObjectModel::lookup(
                    $this->getObjectId(), $this->getObjectType());

        return $this->_object;
    }

    function getNumAttachments() {
        return Attachment::objects()->filter(array(
            'thread_entry__thread' => $this
        ))->count();
Peter Rotich's avatar
Peter Rotich committed
    function getNumEntries() {
        return $this->entries->count();
    }
    function getEntries($criteria=false) {
        $base = $this->entries->annotate(array(
            'has_attachments' => SqlAggregate::COUNT('attachments', false,
                new Q(array('attachments__inline'=>0)))
        $base->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN));
        if ($criteria)
            $base->filter($criteria);
        return $base;
    function render($type=false) {

        $entries = $this->getEntries();
        if ($type && is_array($type))
            $entries->filter(array('type__in' => $type));

        include STAFFINC_DIR . 'templates/thread-entries.tmpl.php';
    }

Peter Rotich's avatar
Peter Rotich committed
    function getEntry($id) {
Peter Rotich's avatar
Peter Rotich committed
        return ThreadEntry::lookup($id, $this->getId());
    /**
     * postEmail
     *
     * After some security and sanity checks, attaches the body and subject
     * of the message in reply to this thread item
     *
     * Parameters:
     * mailinfo - (array) of information about the email, with at least the
     *          following keys
     *      - mid - (string) email message-id
     *      - name - (string) personal name of email originator
     *      - email - (string<email>) originating email address
     *      - subject - (string) email subject line (decoded)
     *      - body - (string) email message body (decoded)
     */
    function postEmail($mailinfo) {
        global $ost;

        // +==================+===================+=============+
        // | Orig Thread-Type | Reply Thread-Type | Requires    |
        // +==================+===================+=============+
        // | *                | Message (M)       | From: Owner |
        // | *                | Note (N)          | From: Staff |
        // | Response (R)     | Message (M)       |             |
        // | Message (M)      | Response (R)      | From: Staff |
        // +------------------+-------------------+-------------+

        if (!$object = $this->getObject()) {
            // How should someone find this thread?
            return false;
        }
        elseif ($object instanceof Ticket && (
               !$mailinfo['staffId']
            && $object->isClosed()
            && !$object->isReopenable()
        )) {
            // Ticket is closed, not reopenable, and email was not submitted
            // by an agent. Email cannot be submitted
            return false;
        }

        // Mail sent by this system will have a message-id format of
        // <code-random-mailbox@domain.tld>
        // where code is a predictable string based on the SECRET_SALT of
        // this osTicket installation. If this incoming mail matches the
        // code, then it very likely originated from this system and looped
        @list($code) = explode('-', $mailinfo['mid'], 2);
        if (0 === strcasecmp(ltrim($code, '<'), substr(md5('mail'.SECRET_SALT), -9))) {
            // This mail was sent by this system. It was received due to
            // some kind of mail delivery loop. It should not be considered
            // a response to an existing thread entry
            if ($ost) $ost->log(LOG_ERR, _S('Email loop detected'), sprintf(
                _S('It appears as though &lt;%s&gt; is being used as a forwarded or fetched email account and is also being used as a user / system account. Please correct the loop or seek technical assistance.'),
                $mailinfo['email']),

                // This is quite intentional -- don't continue the loop
                false,
                // Force the message, even if logging is disabled
                true);
            return true;
        }

        $vars = array(
            'mid' =>    $mailinfo['mid'],
            'header' => $mailinfo['header'],
            'poster' => $mailinfo['name'],
            'origin' => 'Email',
            'source' => 'Email',
            'ip' =>     '',
            'reply_to' => $this,
            'recipients' => $mailinfo['recipients'],
            'to-email-id' => $mailinfo['to-email-id'],
        );

        // XXX: Is this necessary?
        if ($object instanceof Ticket)
            $vars['ticketId'] = $object->getId();
        if ($object instanceof Task)
            $vars['taskId'] = $object->getId();

        $errors = array();

        if (isset($mailinfo['attachments']))
            $vars['attachments'] = $mailinfo['attachments'];

        $body = $mailinfo['message'];
        $poster = $mailinfo['email'];

        // Disambiguate if the user happens also to be a staff member of the
        // system. The current ticket owner should _always_ post messages
        // instead of notes or responses
        if ($mailinfo['userId'] || (
            $object instanceof Ticket
            && strcasecmp($mailinfo['email'], $object->getEmail()) == 0
        )) {
            $vars['message'] = $body;
            $vars['userId'] = $mailinfo['userId'] ?: $object->getUserId();

            if ($object instanceof Threadable)
                return $object->postThreadEntry('M', $vars);
            elseif ($this instanceof ObjectThread)
                $this->addMessage($vars, $errors);
            else
                throw new Exception('Cannot continue discussion with abstract thread');
        }
        // Consider collaborator role (disambiguate staff members as
        // collaborators). Normally, the block above should match based
        // on the Referenced message-id header
        elseif ($object instanceof Ticket
            && ($E = UserEmail::lookup($mailinfo['email']))
            && ($C = Collaborator::lookup(array(
                'ticketId' => $object->getId(), 'userId' => $E->user_id
            )))
        ) {
            $vars['userId'] = $mailinfo['userId'] ?: $C->getUserId();
            $vars['message'] = $body;
            if ($object instanceof Threadable)
                return $object->postThreadEntry('M', $vars);
            elseif ($this instanceof ObjectThread)
                $this->addMessage($vars, $errors);
            else
                throw new Exception('Cannot continue discussion with abstract thread');
        }
        // Accept internal note from staff members' replies
        elseif ($mailinfo['staffId']
                || ($mailinfo['staffId'] = Staff::getIdByEmail($mailinfo['email']))) {
            $vars['staffId'] = $mailinfo['staffId'];
            $vars['poster'] = Staff::lookup($mailinfo['staffId']);
            $vars['note'] = $body;

            if ($object instanceof Threadable)
                return $object->postThreadEntry('N', $vars);
            elseif ($this instanceof ObjectThread)
                return $this->addNote($vars, $errors);
            else
                throw new Exception('Cannot continue discussion with abstract thread');
        }
        elseif (Email::getIdByEmail($mailinfo['email'])) {
            // Don't process the email -- it came FROM this system
            return true;
        }
        // Support the mail parsing system declaring a thread-type
        elseif (isset($mailinfo['thread-type'])) {
            switch ($mailinfo['thread-type']) {
            case 'N':
                $vars['note'] = $body;
                $vars['poster'] = $poster;
                if ($object instanceof Threadable)
                    return $object->postThreadEntry('N', $vars);
                elseif ($this instanceof ObjectThread)
                    return $this->addNote($vars, $errors);
                else
                    throw new Exception('Cannot continue discussion with abstract thread');
            }
        }
        // TODO: Consider security constraints
        else {
            //XXX: Are we potentially leaking the email address to
            // collaborators?
            // Try not to destroy the format of the body
            $header = sprintf("Received From: %s <%s>\n\n", $mailinfo['name'],
                $mailinfo['email']);
            if ($body instanceof HtmlThreadBody)
                $header = nl2br(Format::htmlchars($header));
            // Add the banner to the top of the message
            if ($body instanceof ThreadBody)
                $body->prepend($header);
            $vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner?
            $vars['origin'] = 'Email';
            if ($object instanceof Threadable)
                return $object->postThreadEntry('M', $vars);
            elseif ($this instanceof ObjectThread)
                return $this->addMessage($vars, $errors);
            else
                throw new Exception('Cannot continue discussion with abstract thread');
        }
        // Currently impossible, but indicate that this thread object could
        // not append the incoming email.
        return false;
    }
Peter Rotich's avatar
Peter Rotich committed

    function deleteAttachments() {
        $deleted = Attachment::objects()->filter(array(
            'thread_entry__thread' => $this,
        ))->delete();
Peter Rotich's avatar
Peter Rotich committed
            AttachmentFile::deleteOrphans();
Peter Rotich's avatar
Peter Rotich committed

        return $deleted;
    }

    function removeCollaborators() {
        return Collaborator::objects()
            ->filter(array('thread_id'=>$this->getId()))
            ->delete();
    /**
     * Function: lookupByEmailHeaders
     *
     * Attempt to locate a thread by the email headers. It should be
     * considered a secondary lookup to ThreadEntry::lookupByEmailHeaders(),
     * which should find an actual thread entry, which should be possible
     * for all email communcation which is associated with a thread entry.
     * The only time where this is useful is for threads which triggered
     * email communication without a thread entry, for instance, like
     * tickets created without an initial message.
     */
    function lookupByEmailHeaders(&$mailinfo) {
        $possibles = array();
        foreach (array('in-reply-to', 'references') as $header) {
            $matches = array();
            if (!isset($mailinfo[$header]) || !$mailinfo[$header])
                continue;
            // Header may have multiple entries (usually separated by
            // spaces ( )
            elseif (!preg_match_all('/<[^>@]+@[^>]+>/', $mailinfo[$header],
                        $matches))
                continue;

            // The References header will have the most recent message-id
            // (parent) on the far right.
            // @see rfc 1036, section 2.2.5
            // @see http://www.jwz.org/doc/threading.html
            $possibles = array_merge($possibles, array_reverse($matches[0]));
        }

        // Add the message id if it is embedded in the body
        $match = array();
        if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`',
                $mailinfo['message'], $match)
            && !in_array($match[1], $possibles)
        ) {
            $possibles[] = $match[1];
        }

        foreach ($possibles as $mid) {
            // Attempt to detect the ticket and user ids from the
            // message-id header. If the message originated from
            // osTicket, the Mailer class can break it apart. If it came
            // from this help desk, the 'loopback' property will be set
            // to true.
            $mid_info = Mailer::decodeMessageId($mid);
            if ($mid_info['loopback'] && isset($mid_info['uid'])
                && @$mid_info['threadId']
                && ($t = Thread::lookup($mid_info['threadId']))
            ) {
                if (@$mid_info['userId']) {
                    $mailinfo['userId'] = $mid_info['userId'];
                }
                elseif (@$mid_info['staffId']) {
                    $mailinfo['staffId'] = $mid_info['staffId'];
                }
                // ThreadEntry was positively identified
                return $t;
            }
        }

        return null;
    }

Peter Rotich's avatar
Peter Rotich committed
    function delete() {

Peter Rotich's avatar
Peter Rotich committed
        //Self delete
Peter Rotich's avatar
Peter Rotich committed
            return false;

Peter Rotich's avatar
Peter Rotich committed
        // Clear email meta data (header..etc)
        ThreadEntryEmailInfo::objects()
            ->filter(array('thread_entry__thread' => $this))
            ->update(array('headers' => null));
Peter Rotich's avatar
Peter Rotich committed

        // Mass delete entries
Peter Rotich's avatar
Peter Rotich committed
        $this->deleteAttachments();
        $this->removeCollaborators();
Peter Rotich's avatar
Peter Rotich committed
    static function create($vars) {
        $inst = parent::create($vars);
        $inst->created = SqlFunction::NOW();
        return $inst;
Peter Rotich's avatar
Peter Rotich committed
    }
Peter Rotich's avatar
Peter Rotich committed

class ThreadEntryEmailInfo extends VerySimpleModel {
    static $meta = array(
        'table' => THREAD_ENTRY_EMAIL_TABLE,
        'pk' => array('id'),
        'joins' => array(
            'thread_entry' => array(
                'constraint' => array('thread_entry_id' => 'ThreadEntry.id'),
            ),
        ),
    );
class ThreadEntry extends VerySimpleModel
implements TemplateVariable {
    static $meta = array(
        'table' => THREAD_ENTRY_TABLE,
        'pk' => array('id'),
        'select_related' => array('staff', 'user', 'email_info'),
        'ordering' => array('created'),
        'joins' => array(
            'thread' => array(
                'constraint' => array('thread_id' => 'Thread.id'),
            'parent' => array(
                'constraint' => array('pid' => 'ThreadEntry.id'),
                'null' => true,
            ),
            'children' => array(
                'reverse' => 'ThreadEntry.parent',
            ),
            'email_info' => array(
                'reverse' => 'ThreadEntryEmailInfo.thread_entry',
                'list' => false,
            ),
                'reverse' => 'Attachment.thread_entry',
                'null' => true,
            ),
            'staff' => array(
                'constraint' => array('staff_id' => 'Staff.staff_id'),
                'null' => true,
            ),
            'user' => array(
                'constraint' => array('user_id' => 'User.id'),
    const FLAG_ORIGINAL_MESSAGE         = 0x0001;
    const FLAG_EDITED                   = 0x0002;
    const FLAG_HIDDEN                   = 0x0004;
    const FLAG_GUARDED                  = 0x0008;   // No replace on edit
    const PERM_EDIT     = 'thread.edit';

    static protected $perms = array(
        self::PERM_EDIT => array(
            'title' => /* @trans */ 'Edit Thread',
            'desc'  => /* @trans */ 'Ability to edit thread items of other agents',
        ),
    );

    function postEmail($mailinfo) {
        if (!($thread = $this->getThread()))
            // Kind of hard to continue a discussion without a thread ...
            return false;

        elseif ($this->getEmailMessageId() == $mailinfo['mid'])
            // Reporting success so the email can be moved or deleted.
            return true;

        return $thread->postEmail($mailinfo);
    }

    function getId() {
        return $this->id;
    }

    function getPid() {
        return $this->get('pid', 0);
    function getParent() {
        return $this->parent;
        return ThreadEntryBody::fromFormattedText($this->body, $this->format);
    function setBody($body) {
        global $cfg;

Peter Rotich's avatar
Peter Rotich committed
        if (!$body instanceof ThreadEntryBody) {
            if ($cfg->isHtmlThreadEnabled())
Peter Rotich's avatar
Peter Rotich committed
                $body = new HtmlThreadEntryBody($body);
Peter Rotich's avatar
Peter Rotich committed
                $body = new TextThreadEntryBody($body);
        $this->format = $body->getType();
        $this->body = (string) $body;
        return $this->save();
    function getMessage() {
        return $this->getBody();
    }

    function getCreateDate() {
    }

    function getNumAttachments() {
        return $this->attachments->count();
    function getEmailMessageId() {
        if ($this->email_info)
            return $this->email_info->mid;
    function getEmailHeaderArray() {
        require_once(INCLUDE_DIR.'class.mailparse.php');

        if (!isset($this->_headers) && $this->email_info
            && isset($this->email_info->headers)
        ) {
            $this->_headers = Mail_Parse::splitHeaders($this->email_info->headers);
        }
        return $this->_headers;
    function getEmailReferences($include_mid=true) {
        $references = '';
        $headers = self::getEmailHeaderArray();
        if (isset($headers['References']) && $headers['References'])
            $references = $headers['References']." ";
        if ($include_mid && ($mid = $this->getEmailMessageId()))
            $references .= $mid;
        return $references;
    /**
     * Retrieve a list of all the recients of this message if the message
     * was received via email.
     *
     * Returns:
     * (array<RFC_822>) list of recipients parsed with the Mail/RFC822
     * address parsing utility. Returns an empty array if the message was
     * not received via email.
     */
    function getAllEmailRecipients() {
        $headers = self::getEmailHeaderArray();
        $recipients = array();
        if (!$headers)
            return $recipients;

        foreach (array('To', 'Cc') as $H) {
            if (!isset($headers[$H]))
                continue;

            if (!($all = Mail_Parse::parseAddressList($headers[$H])))
                continue;

            $recipients = array_merge($recipients, $all);
        }
        return $recipients;
    }

    /**
     * Recurse through the ancestry of this thread entry to find the first
     * thread entry which cites a email Message-ID field.
     *
     * Returns:
     * <ThreadEntry> or null if neither this thread entry nor any of its
     * ancestry contains an email header with an email Message-ID header.
     */
    function findOriginalEmailMessage() {
        $P = $this;
        while (!$P->getEmailMessageId()
            && ($P = $P->getParent()));
        return $P;
    }

    function getUIDFromEmailReference($ref) {

        $info = unpack('Vtid/Vuid',
                Base32::decode(strtolower(substr($ref, -13))));

        if ($info && $info['tid'] == $this->getId())
            return $info['uid'];

    }

Peter Rotich's avatar
Peter Rotich committed
    function getThreadId() {
Peter Rotich's avatar
Peter Rotich committed
    }

    function getThread() {
        if (!isset($this->_thread) && $this->thread_id)
            // TODO: Consider typing the thread based on its type field
            $this->_thread = ObjectThread::lookup($this->getThreadId());
        return isset($this->staff_id) ? $this->staff_id : 0;
    }

    function getStaff() {
        return $this->staff;
    }

    function getUserId() {
        return isset($this->user_id) ? $this->user_id : 0;
    function getName() {
        if ($this->staff_id)
            return $this->staff->getName();
        if ($this->user_id)
            return $this->user->getName();
Peter Rotich's avatar
Peter Rotich committed
    function getEmailHeader() {
        if ($this->email_info)
            return $this->email_info->headers;
    function isAutoReply() {
        if (!isset($this->is_autoreply))
            $this->is_autoreply = $this->getEmailHeaderArray()
                ?  TicketFilter::isAutoReply($this->getEmailHeaderArray()) : false;
        return $this->is_autoreply;
    function isBounce() {
        if (!isset($this->is_bounce))
            $this->is_bounce = $this->getEmailHeaderArray()
                ? TicketFilter::isBounce($this->getEmailHeaderArray()) : false;
        return $this->is_bounce;
    function isBounceOrAutoReply() {
        return ($this->isAutoReply() || $this->isBounce());
    function hasFlag($flag) {
        return ($this->get('flags', 0) & $flag) != 0;
    }
    function clearFlag($flag) {
        return $this->set('flags', $this->get('flags') & ~$flag);
    }
    function setFlag($flag) {
        return $this->set('flags', $this->get('flags') | $flag);
    }

Peter Rotich's avatar
Peter Rotich committed
    //Web uploads - caller is expected to format, validate and set any errors.
    function uploadFiles($files) {

        if(!$files || !is_array($files))
            return false;

        $uploaded=array();
        foreach($files as $file) {
            if($file['error'] && $file['error']==UPLOAD_ERR_NO_FILE)
                continue;

            if(!$file['error']
                    && ($F=AttachmentFile::upload($file))
                    && $this->saveAttachment($F))
                $uploaded[]= $F->getId();
Peter Rotich's avatar
Peter Rotich committed
            else {
                if(!$file['error'])
                    $error = sprintf(__('Unable to upload file - %s'),$file['name']);
Peter Rotich's avatar
Peter Rotich committed
                elseif(is_numeric($file['error']))
                    $error ='Error #'.$file['error']; //TODO: Transplate to string.
                else
                    $error = $file['error'];
                /*
                 Log the error as an internal note.
                 XXX: We're doing it here because it will eventually become a thread post comment (hint: comments coming!)
                 XXX: logNote must watch for possible loops
               */
                $this->getThread()->getObject()->logNote(__('File Upload Error'), $error, 'SYSTEM', false);
    function importAttachments(&$attachments) {
Peter Rotich's avatar
Peter Rotich committed

        if(!$attachments || !is_array($attachments))
            return null;

        $files = array();
        foreach($attachments as &$attachment)
Peter Rotich's avatar
Peter Rotich committed
            if(($id=$this->importAttachment($attachment)))
                $files[] = $id;

        return $files;
    }

    /* Emailed & API attachments handler */
    function importAttachment(&$attachment) {
Peter Rotich's avatar
Peter Rotich committed

        if(!$attachment || !is_array($attachment))
            return null;

        $A=null;
        if ($attachment['error'] || !($A=$this->saveAttachment($attachment))) {
Peter Rotich's avatar
Peter Rotich committed
            $error = $attachment['error'];
            if(!$error)
                $error = sprintf(_S('Unable to import attachment - %s'),
                        $attachment['name']);
Peter Rotich's avatar
Peter Rotich committed
            //FIXME: logComment here
            $this->getThread()->getObject()->logNote(
                    _S('File Import Error'), $error, _S('SYSTEM'), false);
Peter Rotich's avatar
Peter Rotich committed
    }

   /*
    Save attachment to the DB.
    @file is a mixed var - can be ID or file hashtable.
    */
    function saveAttachment(&$file) {
        $inline = is_array($file) && @$file['inline'];

        if (is_numeric($file))
            $fileId = $file;
        elseif ($file instanceof AttachmentFile)
            $fileId = $file->getId();
        elseif ($F = AttachmentFile::create($file))
            $fileId = $F->getId();
        elseif (is_array($file) && isset($file['id']))
            $fileId = $file['id'];
        else
            return false;

        $att = Attachment::create(array(
            'type' => 'H',
            'object_id' => $this->getId(),
            'file_id' => $fileId,
            'inline' => $inline ? 1 : 0,
        ));
        if (!$att->save())
            return false;
        return $att;
Peter Rotich's avatar
Peter Rotich committed
    }

    function saveAttachments($files) {
Peter Rotich's avatar
Peter Rotich committed
        foreach ($files as $file)
           if (($A = $this->saveAttachment($file)))
               $attachments[] = $A;
Peter Rotich's avatar
Peter Rotich committed
    }

    function getAttachments() {
    function getAttachmentUrls() {
        foreach ($this->attachments as $att) {
            $json[$att->file->getKey()] = array(
                'download_url' => $att->file->getDownloadUrl(),
                'filename' => $att->file->name,
    function getAttachmentsLinks($file='attachment.php', $target='_blank', $separator=' ') {
        // TODO: Move this to the respective UI templates
Peter Rotich's avatar
Peter Rotich committed

        $str='';
        foreach ($this->attachments as $att ) {
            if ($att->inline) continue;
Peter Rotich's avatar
Peter Rotich committed
            $size = '';
            if ($att->file->size)
                $size=sprintf('<em>(%s)</em>', Format::file_size($att->file->size));
            $str .= sprintf(
                '<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%s&nbsp;%s',
                $att->file->getDownloadUrl(), $target,
                Format::htmlchars($att->file->name), $size, $separator);
Peter Rotich's avatar
Peter Rotich committed
        }

        return $str;
    }

    /* save email info
     * TODO: Refactor it to include outgoing emails on responses.
     */

    function saveEmailInfo($vars) {

        // Don't save empty message ID
        if (!$vars || !$vars['mid'])
Peter Rotich's avatar
Peter Rotich committed
            return 0;

        $this->ht['email_mid'] = $vars['mid'];
        $header = false;
        if (isset($vars['header']))
            $header = $vars['header'];
        self::logEmailHeaders($this->getId(), $vars['mid'], $header);
    /* static */
    function logEmailHeaders($id, $mid, $header=false) {
Peter Rotich's avatar
Peter Rotich committed

        if (!$id || !$mid)
            return false;

        $this->email_info = ThreadEntryEmailInfo::create(array(
            'thread_entry_id' => $id,
            'mid' => $mid,
        ));

            $this->email_info->headers = trim($header);
Peter Rotich's avatar
Peter Rotich committed

        return $this->email_info->save();
    function __toString() {
        return (string) $this->getBody();
    // TemplateVariable interface
        return (string) $this->getBody()->display('email');
    function getVar($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':
                return new FormattedDate($this->getCreateDate());
                return new FormattedDate($this->getUpdateDate());
            case 'files':
                throw new OOBContent(OOBContent::FILES, $this->attachments->all());
    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'),
            'class' => 'User', 'desc' => __('User posting the message'),
    /**
     * Parameters:
     * mailinfo (hash<String>) email header information. Must include keys
     *  - "mid" => Message-Id header of incoming mail
     *  - "in-reply-to" => Message-Id the email is a direct response to
     *  - "references" => List of Message-Id's the email is in response
     *  - "subject" => Find external ticket number in the subject line
     *
     *  seen (by-ref:bool) a flag that will be set if the message-id was
     *      positively found, indicating that the message-id has been
     *      previously seen. This is useful if no thread-id is associated
     *      with the email (if it was rejected for instance).
    function lookupByEmailHeaders(&$mailinfo, &$seen=false) {
        // Search for messages using the References header, then the
        // in-reply-to header
        if ($entry = ThreadEntry::objects()
            ->filter(array('email_info__mid' => $mailinfo['mid']))
            ->first()
        ) {
            $seen = true;
        $possibles = array();
        foreach (array('in-reply-to', 'references') as $header) {
            $matches = array();
            if (!isset($mailinfo[$header]) || !$mailinfo[$header])
                continue;
            // Header may have multiple entries (usually separated by
            // spaces ( )
            elseif (!preg_match_all('/<[^>@]+@[^>]+>/', $mailinfo[$header],
                        $matches))
                continue;

            // The References header will have the most recent message-id
            // (parent) on the far right.
            // @see rfc 1036, section 2.2.5
            // @see http://www.jwz.org/doc/threading.html
            $possibles = array_merge($possibles, array_reverse($matches[0]));
        }

        // Add the message id if it is embedded in the body
        $match = array();
        if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`',
                $mailinfo['message'], $match)
            && !in_array($match[1], $possibles)
        ) {
            $possibles[] = $match[1];
        }

        $thread = null;
        foreach ($possibles as $mid) {
            // Attempt to detect the ticket and user ids from the
            // message-id header. If the message originated from
            // osTicket, the Mailer class can break it apart. If it came
            // from this help desk, the 'loopback' property will be set
            // to true.
            $mid_info = Mailer::decodeMessageId($mid);
            if ($mid_info['loopback'] && isset($mid_info['uid'])
                && @$mid_info['entryId']
                && ($t = ThreadEntry::lookup($mid_info['entryId']))
                && ($t->thread_id == $mid_info['threadId'])
            ) {
                if (@$mid_info['userId']) {
                    $mailinfo['userId'] = $mid_info['userId'];
                }
                elseif (@$mid_info['staffId']) {
                    $mailinfo['staffId'] = $mid_info['staffId'];
                }
                // ThreadEntry was positively identified
                return $t;
            }

            // Try to determine if it's a reply to a tagged email.
            // (Deprecated)
            $ref = null;
            if (strpos($mid, '+')) {
                list($left, $right) = explode('@',$mid);
                list($left, $ref) = explode('+', $left);
                $mid = "$left@$right";
            }
            $entries = ThreadEntry::objects()
                ->filter(array('email_info__mid' => $mid));
            foreach ($entries as $t) {
                // Capture the first match thread item