Skip to content
Snippets Groups Projects
class.thread.php 48.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');
class ThreadModel extends VerySimpleModel {
    static $meta = array(
        'table' => THREAD_TABLE,
        'pk' => array('id'),
        'joins' => array(
            'ticket' => array(
                'constraint' => array(
                    'object_id' => 'TicketModel.ticket_id',
                    'object_type' => "'T'",
                ),
            ),
            'entries' => array(
                'reverse' => 'ThreadEntryModel.thread',
            ),
        ),
    );
}

Peter Rotich's avatar
Peter Rotich committed
//Ticket thread.
class Thread {

Peter Rotich's avatar
Peter Rotich committed
    var $ht;
    function Thread($criteria) {
        $this->load($criteria);
    function load($criteria=null) {
        if (!$criteria && !($criteria=$this->getId()))
Peter Rotich's avatar
Peter Rotich committed
            return null;

Peter Rotich's avatar
Peter Rotich committed
        $sql='SELECT thread.* '
            .' ,count(DISTINCT a.id) as attachments '
Peter Rotich's avatar
Peter Rotich committed
            .' ,count(DISTINCT entry.id) as entries '
            .' FROM '.THREAD_TABLE.' thread '
            .' LEFT JOIN '.THREAD_ENTRY_TABLE.' entry
                ON (entry.thread_id = thread.id) '
            .' LEFT JOIN '.ATTACHMENT_TABLE.' a
                ON (a.object_id=entry.id AND a.`type` = "H") ';

        if (is_numeric($criteria))
            $sql.= ' WHERE thread.id='.db_input($criteria);
        else
            $sql.= sprintf(' WHERE thread.object_id=%d AND
                    thread.object_type=%s',
                    $criteria['object_id'],
                    db_input($criteria['object_type']));

        $sql.= ' GROUP BY thread.id';
Peter Rotich's avatar
Peter Rotich committed
        $this->ht = array();
        if (($res=db_query($sql)) && db_num_rows($res))
            $this->ht = db_fetch_array($res);
Peter Rotich's avatar
Peter Rotich committed
        return ($this->ht);
    }
Peter Rotich's avatar
Peter Rotich committed
    function reload() {
        return $this->load();
Peter Rotich's avatar
Peter Rotich committed
    }

    function getId() {
Peter Rotich's avatar
Peter Rotich committed
        return $this->ht['id'];
Peter Rotich's avatar
Peter Rotich committed
    function getObjectId() {
        return $this->ht['object_id'];
Peter Rotich's avatar
Peter Rotich committed
    function getObjectType() {
        return $this->ht['object_type'];
    function getObject() {

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

        return $this->_object;
    }

    function getNumAttachments() {
        return $this->ht['attachments'];
Peter Rotich's avatar
Peter Rotich committed
    function getNumEntries() {
        return $this->ht['entries'];
    function getEntries($criteria) {
        if (!$criteria['order'] || !in_array($criteria['order'], array('DESC','ASC')))
            $criteria['order'] = 'ASC';
Peter Rotich's avatar
Peter Rotich committed
        $sql='SELECT entry.*
               , COALESCE(user.name,
                    IF(staff.staff_id,
                        CONCAT_WS(" ", staff.firstname, staff.lastname),
                        NULL)) as name '
Peter Rotich's avatar
Peter Rotich committed
            .' ,count(DISTINCT attach.id) as attachments '
            .' FROM '.THREAD_ENTRY_TABLE.' entry '
Peter Rotich's avatar
Peter Rotich committed
                ON (entry.user_id=user.id) '
Peter Rotich's avatar
Peter Rotich committed
                ON (entry.staff_id=staff.staff_id) '
            .' LEFT JOIN '.ATTACHMENT_TABLE.' attach
                ON (attach.object_id = entry.id AND attach.`type`="H") '
Peter Rotich's avatar
Peter Rotich committed
            .' WHERE  entry.thread_id='.db_input($this->getId());
        if ($criteria['type'] && is_array($criteria['type']))
            $sql.=' AND entry.`type` IN ('
                    .implode(',', db_input($criteria['type'])).')';
        elseif ($criteria['type'])
            $sql.=' AND entry.`type` = '.db_input($criteria['type']);
Peter Rotich's avatar
Peter Rotich committed
        $sql.=' GROUP BY entry.id '
             .' ORDER BY entry.created '.$criteria['order'];

        if ($criteria['limit'])
            $sql.=' LIMIT '.$criteria['limit'];
Peter Rotich's avatar
Peter Rotich committed

        $entries = array();
        if(($res=db_query($sql)) && db_num_rows($res)) {
            while($rec=db_fetch_array($res)) {
Peter Rotich's avatar
Peter Rotich committed
                $rec['body'] = ThreadEntryBody::fromFormattedText($rec['body'], $rec['format']);
Peter Rotich's avatar
Peter Rotich committed
                $entries[] = $rec;
Peter Rotich's avatar
Peter Rotich committed

        return $entries;
    }

    function getEntry($id) {
Peter Rotich's avatar
Peter Rotich committed
        return ThreadEntry::lookup($id, $this->getId());
Peter Rotich's avatar
Peter Rotich committed
    }


    function deleteAttachments() {

        // Clear reference table
        $sql = 'DELETE `a`.* FROM '.ATTACHMENT_TABLE. ' `a` '
             . 'INNER JOIN '.THREAD_ENTRY_TABLE.' `e`
                    ON(`e`.id = `a`.object_id AND `a`.`type`= "H") '
             . ' WHERE `e`.thread_id='.db_input($this->getId());
Peter Rotich's avatar
Peter Rotich committed

        $deleted=0;
        if (($res=db_query($sql)) && ($deleted=db_affected_rows()))
            AttachmentFile::deleteOrphans();
Peter Rotich's avatar
Peter Rotich committed

        return $deleted;
    }

    function delete() {

Peter Rotich's avatar
Peter Rotich committed
        //Self delete
        $sql = 'DELETE FROM '.THREAD_TABLE.' WHERE
            id='.db_input($this->getId());
Peter Rotich's avatar
Peter Rotich committed
        if (!db_query($sql) || !db_affected_rows())
Peter Rotich's avatar
Peter Rotich committed
            return false;

Peter Rotich's avatar
Peter Rotich committed
        // Clear email meta data (header..etc)
        $sql = 'UPDATE '.THREAD_ENTRY_EMAIL_TABLE.' email '
             . 'INNER JOIN '.THREAD_ENTRY_TABLE.' entry
                    ON (entry.id = email.thread_entry_id) '
             . 'SET email.headers = null '
             . 'WHERE entry.thread_id = '.db_input($this->getId());
        db_query($sql);

        // Mass delete entries
Peter Rotich's avatar
Peter Rotich committed
        $this->deleteAttachments();
Peter Rotich's avatar
Peter Rotich committed
        $sql = 'DELETE FROM '.THREAD_ENTRY_TABLE
             . ' WHERE thread_id='.db_input($this->getId());
        db_query($sql);
Peter Rotich's avatar
Peter Rotich committed
    static function create($vars) {

        if (!$vars || !$vars['object_id'] || !$vars['object_type'])
            return false;

        $sql = 'INSERT INTO '.THREAD_TABLE.' SET created=NOW() '
              .', object_id='.db_input($vars['object_id'])
              .', object_type='.db_input($vars['object_type']);

        if (db_query($sql))
            return static::lookup(db_insert_id());

        return null;
Peter Rotich's avatar
Peter Rotich committed
    }

    static function lookup($id) {

        return ($id
                && ($thread = new Thread($id))
                && $thread->getId()
                )
            ? $thread : null;
    }
class ThreadEntryModel extends VerySimpleModel {
    static $meta = array(
        'table' => THREAD_ENTRY_TABLE,
        'pk' => array('id'),
        'joins' => array(
            'thread' => array(
                'constraint' => array('thread_id' => 'ThreadModel.id'),
            ),
            'attachments' => array(
                'reverse' => 'AttachmentModel.thread',
                'null' => true,
            ),
        ),
    );
}
Peter Rotich's avatar
Peter Rotich committed
    var $thread;
Peter Rotich's avatar
Peter Rotich committed
    var $attachments;

Peter Rotich's avatar
Peter Rotich committed
    function ThreadEntry($id, $threadId=0, $type='') {
        $this->load($id, $threadId, $type);
Peter Rotich's avatar
Peter Rotich committed
    function load($id=0, $threadId=0, $type='') {
Peter Rotich's avatar
Peter Rotich committed
        if (!$id && !($id=$this->getId()))
Peter Rotich's avatar
Peter Rotich committed
        $sql='SELECT entry.*, email.mid, email.headers '
            .' ,count(DISTINCT attach.id) as attachments '
            .' FROM '.THREAD_ENTRY_TABLE.' entry '
            .' LEFT JOIN '.THREAD_ENTRY_EMAIL_TABLE.' email
                ON (email.thread_entry_id=entry.id) '
            .' LEFT JOIN '.ATTACHMENT_TABLE.' attach
                ON (attach.object_id=entry.id AND attach.`type` = "H") '
Peter Rotich's avatar
Peter Rotich committed
            .' WHERE  entry.id='.db_input($id);
Peter Rotich's avatar
Peter Rotich committed
        if ($type)
            $sql.=' AND entry.type='.db_input($type);
Peter Rotich's avatar
Peter Rotich committed
        if ($threadId)
            $sql.=' AND entry.thread_id='.db_input($threadId);
Peter Rotich's avatar
Peter Rotich committed
        $sql.=' GROUP BY entry.id ';
Peter Rotich's avatar
Peter Rotich committed
        if (!($res=db_query($sql)) || !db_num_rows($res))
            return false;

        $this->ht = db_fetch_array($res);
        $this->id = $this->ht['id'];
        $this->attachments = new GenericAttachments($this->id, 'H');

        return true;
    }

    function reload() {
        return $this->load();
    }

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

    function getPid() {
        return $this->ht['pid'];
    }

    function getType() {
Peter Rotich's avatar
Peter Rotich committed
        return $this->ht['type'];
    }

    function getSource() {
        return $this->ht['source'];
    }

    function getPoster() {
        return $this->ht['poster'];
    }

    function getTitle() {
        return $this->ht['title'];
    }

    function getBody() {
Peter Rotich's avatar
Peter Rotich committed
        return ThreadEntryBody::fromFormattedText($this->ht['body'], $this->ht['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);
Peter Rotich's avatar
Peter Rotich committed
        $sql='UPDATE '.THREAD_ENTRY_TABLE.' SET updated=NOW()'
            .',format='.db_input($body->getType())
            .',body='.db_input((string) $body)
            .' WHERE id='.db_input($this->getId());
        return db_query($sql) && db_affected_rows();
    }

    function getCreateDate() {
        return $this->ht['created'];
    }

    function getUpdateDate() {
        return $this->ht['updated'];
    }

    function getNumAttachments() {
        return $this->ht['attachments'];
    }

    function getEmailMessageId() {
Peter Rotich's avatar
Peter Rotich committed
        return $this->ht['mid'];
    function getEmailHeaderArray() {
        require_once(INCLUDE_DIR.'class.mailparse.php');

        if (!isset($this->ht['@headers']))
            $this->ht['@headers'] = Mail_Parse::splitHeaders($this->ht['headers']);

        return $this->ht['@headers'];
    function getEmailReferences($include_mid=true) {
        $references = '';
        $headers = self::getEmailHeaderArray();
        if (isset($headers['References']) && $headers['References'])
            $references = $headers['References']." ";
        if ($include_mid)
            $references .= $this->getEmailMessageId();
        return $references;
    function getTaggedEmailReferences($prefix, $refId) {

        $ref = "+$prefix".Base32::encode(pack('VV', $this->getId(), $refId));

        $mid = substr_replace($this->getEmailMessageId(),
                $ref, strpos($this->getEmailMessageId(), '@'), 0);

        return sprintf('%s %s', $this->getEmailReferences(false), $mid);
    }

    function getEmailReferencesForUser($user) {
        return $this->getTaggedEmailReferences('u',
            ($user instanceof Collaborator)
                ? $user->getUserId()
                : $user->getId());
    }

    function getEmailReferencesForStaff($staff) {
        return $this->getTaggedEmailReferences('s', $staff->getId());
    }

    function getUIDFromEmailReference($ref) {

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

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

    }

Peter Rotich's avatar
Peter Rotich committed
    function getThreadId() {
        return $this->ht['thread_id'];
    }

    function getThread() {
Peter Rotich's avatar
Peter Rotich committed
        if(!$this->thread && $this->getThreadId())
            $this->thread = Thread::lookup($this->getThreadId());
Peter Rotich's avatar
Peter Rotich committed
        return $this->thread;
    }

    function getStaffId() {
        return $this->ht['staff_id'];
    }

    function getStaff() {
        if(!$this->staff && $this->getStaffId())
            $this->staff = Staff::lookup($this->getStaffId());

        return $this->staff;
    }

    function getUserId() {
        return $this->ht['user_id'];
    }

    function getUser() {

        if (!isset($this->user))
            $this->user = User::lookup($this->getUserId());
Peter Rotich's avatar
Peter Rotich committed
    function getEmailHeader() {
        return $this->ht['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());
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']
                    && ($id=AttachmentFile::upload($file))
                    && $this->saveAttachment($id))
                $uploaded[]=$id;
            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;

        $id=0;
        if ($attachment['error'] || !($id=$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
        }

        return $id;
    }

   /*
    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'];

        return $this->attachments->save($file, $inline);
Peter Rotich's avatar
Peter Rotich committed
    }

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

        return $ids;
    }

    function getAttachments() {
Peter Rotich's avatar
Peter Rotich committed
        return $this->attachments->getAll(false);
    function getAttachmentUrls($script='image.php') {
        $json = array();
        foreach ($this->getAttachments() as $att) {
            $json[$att['key']] = array(
                'download_url' => sprintf('attachment.php?id=%d&h=%s',
                    $att['attach_id'], $att['download']),
                'filename' => $att['name'],
            );
        }
    function getAttachmentsLinks($file='attachment.php', $target='_blank', $separator=' ') {
Peter Rotich's avatar
Peter Rotich committed

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

        return $str;
    }
    /**
     * postEmail
     *
     * After some security and sanity checks, attaches the body and subject
     * of the message in reply to this thread item
     *
     * Parameters:
     * mailinfo - (array) of information about the email, with at least the
     *          following keys
     *      - mid - (string) email message-id
     *      - name - (string) personal name of email originator
     *      - email - (string<email>) originating email address
     *      - subject - (string) email subject line (decoded)
     *      - body - (string) email message body (decoded)
     */
    function postEmail($mailinfo) {
        // +==================+===================+=============+
        // | Orig Thread-Type | Reply Thread-Type | Requires    |
        // +==================+===================+=============+
        // | *                | Message (M)       | From: Owner |
        // | *                | Note (N)          | From: Staff |
        // | Response (R)     | Message (M)       |             |
        // | Message (M)      | Response (R)      | From: Staff |
        // +------------------+-------------------+-------------+

        if (!$ticket = $this->getTicket())
            // Kind of hard to continue a discussion without a ticket ...
            return false;
        // Make sure the email is NOT already fetched... (undeleted emails)
        elseif ($this->getEmailMessageId() == $mailinfo['mid'])
            // Reporting success so the email can be moved or deleted.
            return true;

Jared Hancock's avatar
Jared Hancock committed
        // 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);
Jared Hancock's avatar
Jared Hancock committed
            return true;
        }

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

        $body = $mailinfo['message'];

        // Disambiguate if the user happens also to be a staff member of the
        // system. The current ticket owner should _always_ post messages
        // instead of notes or responses
        if ($mailinfo['userId']
                || strcasecmp($mailinfo['email'], $ticket->getEmail()) == 0) {
            $vars['message'] = $body;
            $vars['userId'] = $mailinfo['userId'] ? $mailinfo['userId'] : $ticket->getUserId();
            return $ticket->postMessage($vars, 'Email');
        }
        // XXX: Consider collaborator role
        elseif ($mailinfo['staffId']
                || ($mailinfo['staffId'] = Staff::getIdByEmail($mailinfo['email']))) {
            $vars['staffId'] = $mailinfo['staffId'];
            $poster = Staff::lookup($mailinfo['staffId']);
            $vars['note'] = $body;
            return $ticket->postNote($vars, $errors, $poster);
        }
        elseif (Email::getIdByEmail($mailinfo['email'])) {
            // Don't process the email -- it came FROM this system
            return true;
        }
        // Support the mail parsing system declaring a thread-type
        elseif (isset($mailinfo['thread-type'])) {
            switch ($mailinfo['thread-type']) {
            case 'N':
                $vars['note'] = $body;
                $poster = $mailinfo['email'];
                return $ticket->postNote($vars, $errors, $poster);
            }
        }
        // TODO: Consider security constraints
        else {
            //XXX: Are we potentially leaking the email address to
            // collaborators?
            $vars['message'] = sprintf("Received From: %s\n\n%s",
                $mailinfo['email'], $body);
            $vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner?
            return $ticket->postMessage($vars, 'Email');
        }
        // Currently impossible, but indicate that this thread object could
        // not append the incoming email.
        return false;
    }
Peter Rotich's avatar
Peter Rotich committed

    /* Returns file names with id as key */
    function getFiles() {

        $files = array();
        foreach($this->getAttachments() as $attachment)
            $files[$attachment['file_id']] = $attachment['name'];

        return $files;
    }


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

    function saveEmailInfo($vars) {

        if(!$vars || !$vars['mid'])
            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;

        $sql='INSERT INTO '.THREAD_ENTRY_EMAIL_TABLE
            .' SET thread_entry_id='.db_input($id)
            .', mid='.db_input($mid);
        if ($header)
            $sql .= ', headers='.db_input($header);
Peter Rotich's avatar
Peter Rotich committed

        return db_query($sql) ? db_insert_id() : 0;
    function __toString() {
        return (string) $this->getBody();
        return (string) $this->getBody()->display('email');
    function getVar($tag) {
        global $cfg;

        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 Format::datetime($this->getUpdateDate(), true, 'UTC');
Peter Rotich's avatar
Peter Rotich committed
    static function lookup($id, $tid=0, $type='') {
Peter Rotich's avatar
Peter Rotich committed
        return ($id
                && is_numeric($id)
Peter Rotich's avatar
Peter Rotich committed
                && ($e = new ThreadEntry($id, $tid, $type))
                && $e->getId()==$id
                )?$e:null;
    /**
     * 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
Peter Rotich's avatar
Peter Rotich committed
        $search = 'SELECT thread_entery_id, mid FROM '.THREAD_ENTRY_EMAIL_TABLE
               . ' WHERE mid=%s '
               . ' ORDER BY thread_entry_id DESC';
        if (list($id, $mid) = db_fetch_row(db_query(
                sprintf($search, db_input($mailinfo['mid']))))) {
            $seen = true;
            return ThreadEntry::lookup($id);
        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
            foreach (array_reverse($matches[0]) as $mid) {
                //Try to determine if it's a reply to a tagged email.
                $ref = null;
                if (strpos($mid, '+')) {
                    list($left, $right) = explode('@',$mid);
                    list($left, $ref) = explode('+', $left);
                    $mid = "$left@$right";
                }
                $res = db_query(sprintf($search, db_input($mid)));
                while (list($id) = db_fetch_row($res)) {
                    if (!($t = ThreadEntry::lookup($id)))
                        continue;
                    // Capture the first match thread item
                    if (!$thread)
                        $thread = $t;
                    // We found a match  - see if we can ID the user.
                    // XXX: Check access of ref is enough?
                    if ($ref && ($uid = $t->getUIDFromEmailReference($ref))) {
                        if ($ref[0] =='s') //staff
                            $mailinfo['staffId'] = $uid;
                        else // user or collaborator.
                            $mailinfo['userId'] = $uid;

                        // Best possible case — found the thread and the
                        // user
                        return $t;
                    }
            // Second best case — found a thread but couldn't identify the
            // user from the header. Return the first thread entry matched
            if ($thread)
                return $thread;
        }

        // Search for ticket by the [#123456] in the subject line
        // This is the last resort -  emails must match to avoid message
        // injection by third-party.
        $subject = $mailinfo['subject'];
        $match = array();
        if ($subject
                && $mailinfo['email']
                && preg_match("/\b#(\S+)/u", $subject, $match)
                //Lookup by ticket number
                && ($ticket = Ticket::lookupByNumber($match[1]))
                //Lookup the user using the email address
                && ($user = User::lookup(array('emails__address' => $mailinfo['email'])))) {
            //We have a valid ticket and user
            if ($ticket->getUserId() == $user->getId() //owner
                    ||  ($c = Collaborator::lookup( // check if collaborator
                            array('userId' => $user->getId(),
                                  'ticketId' => $ticket->getId())))) {

                $mailinfo['userId'] = $user->getId();
                return $ticket->getLastMessage();
            }
        }
        // Search for the message-id token in the body
        if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`',
                $mailinfo['message'], $match))
            if ($thread = ThreadEntry::lookupByRefMessageId($match[1],
                    $mailinfo['email']))
                return $thread;
    /**
     * Find a thread entry from a message-id created from the
     * ::asMessageId() method
     */
    function lookupByRefMessageId($mid, $from) {
        $mid = trim($mid, '<>');
        list($ver, $ids, $mails) = explode('$', $mid, 3);

        // Current version is <null>
        if ($ver !== '')
            return false;

        $ids = @unpack('Vthread', base64_decode($ids));
        if (!$ids || !$ids['thread'])
            return false;

        $thread = ThreadEntry::lookup($ids['thread']);
        if (!$thread)
            return false;

        if (0 === strcasecmp($thread->asMessageId($from, $ver), $mid))
            return $thread;
    }

    /**
     * Get an email message-id that can be used to represent this thread
     * entry. The same message-id can be passed to ::lookupByRefMessageId()
     * to find this thread entry
     *
     * Formats:
     * Initial (version <null>)
     * <$:b32(thread-id)$:md5(to-addr.ticket-num.ticket-id)@:md5(url)>
     *      thread-id - thread-id, little-endian INT, packed
     *      :b32() - base32 encoded
     *      to-addr - individual email recipient
     *      ticket-num - external ticket number
     *      ticket-id - internal ticket id
     *      :md5() - last 10 hex chars of MD5 sum
     *      url - helpdesk URL
     */
    function asMessageId($to, $version=false) {
        global $ost;

        $domain = md5($ost->getConfig()->getURL());
Peter Rotich's avatar
Peter Rotich committed
        $ticket = $this->getThread()->getObject();
        return sprintf('$%s$%s@%s',
            base64_encode(pack('V', $this->getId())),
            substr(md5($to . $ticket->getNumber() . $ticket->getId()), -10),
            substr($domain, -10)
        );
    }

Peter Rotich's avatar
Peter Rotich committed
    //new entry ... we're trusting the caller to check validity of the data.
Peter Rotich's avatar
Peter Rotich committed
    static function create($vars) {
Peter Rotich's avatar
Peter Rotich committed

        //Must have...
Peter Rotich's avatar
Peter Rotich committed
        if (!$vars['threadId'] || !$vars['type'])
Peter Rotich's avatar
Peter Rotich committed
            return false;

Peter Rotich's avatar
Peter Rotich committed
        if (!$vars['body'] instanceof ThreadEntryBody) {
Peter Rotich's avatar
Peter Rotich committed
                $vars['body'] = new HtmlThreadEntryBody($vars['body']);
Peter Rotich's avatar
Peter Rotich committed
                $vars['body'] = new TextThreadEntryBody($vars['body']);
        // Drop stripped images
        if ($vars['attachments']) {
            foreach ($vars['body']->getStrippedImages() as $cid) {
                foreach ($vars['attachments'] as $i=>$a) {
                    if (@$a['cid'] && $a['cid'] == $cid) {
                        // Inline referenced attachment was stripped
                        unset($vars['attachments'][$i]);
        // Handle extracted embedded images (<img src="data:base64,..." />).
Peter Rotich's avatar
Peter Rotich committed
        // The extraction has already been performed in the ThreadEntryBody
        // class. Here they should simply be added to the attachments list
        if ($atts = $vars['body']->getEmbeddedHtmlImages()) {
            if (!is_array($vars['attachments']))
                $vars['attachments'] = array();
            foreach ($atts as $info) {
                $vars['attachments'][] = $info;
            }
        }

        if (!($body = $vars['body']->getClean()))
            $body = '-'; //Special tag used to signify empty message as stored.
        $poster = $vars['poster'];
        if ($poster && is_object($poster))
Peter Rotich's avatar
Peter Rotich committed
            $poster = (string) $poster;
Peter Rotich's avatar
Peter Rotich committed
        $sql=' INSERT INTO '.THREAD_ENTRY_TABLE.' SET `created` = NOW() '
            .' ,`type` = '.db_input($vars['type'])
            .' ,`thread_id` = '.db_input($vars['threadId'])
            .' ,`title` = '.db_input(Format::sanitize($vars['title'], true))
            .' ,`format` = '.db_input($vars['body']->getType())
            .' ,`staff_id` = '.db_input($vars['staffId'])
            .' ,`user_id` = '.db_input($vars['userId'])
            .' ,`poster` = '.db_input($poster)