<?php
/**
 * Copyright 2002-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (GPL). If you
 * did not receive this file, see http://www.horde.org/licenses/gpl.
 *
 * @category  Horde
 * @copyright 2002-2017 Horde LLC
 * @license   http://www.horde.org/licenses/gpl GPL
 * @package   IMP
 */

/**
 * The IMP_Contents:: class contains all functions related to handling the
 * content and output of mail messages in IMP.
 *
 * @author    Michael Slusarz <slusarz@horde.org>
 * @category  Horde
 * @copyright 2002-2017 Horde LLC
 * @license   http://www.horde.org/licenses/gpl GPL
 * @package   IMP
 */
class IMP_Contents
{
    /* Mask entries for getSummary(). */
    const SUMMARY_BYTES = 1;
    const SUMMARY_SIZE = 2;
    const SUMMARY_ICON = 4;
    const SUMMARY_ICON_RAW = 16384;
    const SUMMARY_DESCRIP = 8;
    const SUMMARY_DESCRIP_LINK = 16;
    const SUMMARY_DOWNLOAD = 32;
    const SUMMARY_DOWNLOAD_ZIP = 64;
    const SUMMARY_IMAGE_SAVE = 128;
    const SUMMARY_PRINT = 256;
    const SUMMARY_PRINT_STUB = 512;
    const SUMMARY_STRIP = 1024;

    /* Rendering mask entries. */
    const RENDER_FULL = 1;
    const RENDER_INLINE = 2;
    const RENDER_INLINE_DISP_NO = 4;
    const RENDER_INFO = 8;
    const RENDER_INLINE_AUTO = 16;
    const RENDER_RAW = 32;
    const RENDER_RAW_FALLBACK = 64;

    /* Header return type for getHeader(). */
    const HEADER_OB = 1;
    const HEADER_TEXT = 2;
    const HEADER_STREAM = 3;

    /**
     * Have we scanned for embedded parts?
     *
     * @var boolean
     */
    protected $_build = false;

    /**
     * The list of MIME IDs that consist of embedded data.
     *
     * @var array
     */
    protected $_embedded = array();

    /**
     * Message header.
     *
     * @var mixed
     */
    protected $_header;

    /**
     * The index of the current message.
     *
     * @var IMP_Indices_Mailbox
     */
    protected $_indices;

    /**
     * The Horde_Mime_Part object for the message.
     *
     * @var Horde_Mime_Part
     */
    protected $_message;

    /**
     * Cached data for the MIME Viewer objects.
     *
     * @var object
     */
    protected $_viewcache;

    /**
     * Constructor.
     *
     * @param mixed $in  An IMP_Indices_Mailbox or Horde_Mime_Part object.
     *
     * @throws IMP_Exception
     */
    public function __construct($in)
    {
        if ($in instanceof Horde_Mime_Part) {
            $this->_message = $in;
        } else {
            $this->_indices = $in;

            /* Get the Horde_Mime_Part object for the given UID. */
            $query = new Horde_Imap_Client_Fetch_Query();
            $query->structure();

            if (!($ret = $this->_fetchData($query))) {
                $e = new IMP_Exception(_("Error displaying message: message does not exist on server."));
                $e->setLogLevel('NOTICE');
                throw $e;
            }

            $this->_message = $ret->getStructure();
        }
    }

    /**
     * String representation of object.
     *
     * @return string  The indices string.
     */
    public function __toString()
    {
        return strval($this->getIndicesOb());
    }

    /**
     * Returns the IMAP UID for the current message.
     *
     * @return integer  The message UID.
     */
    public function getUid()
    {
        list(,$uid) = $this->_indices->getSingle();
        return $uid;
    }

    /**
     * Returns the IMAP mailbox for the current message.
     *
     * @return IMP_Mailbox  The message mailbox.
     */
    public function getMailbox()
    {
        list($mbox,) = $this->_indices->getSingle();
        return $mbox;
    }

    /**
     * Return an IMP_Indices object for the current message.
     *
     * @return IMP_Indices  An indices object.
     */
    public function getIndicesOb()
    {
        return $this->_indices;
    }

    /**
     * Returns the entire body of the message.
     *
     * @param array $options  Additional options:
     *   - stream: (boolean) If true, return a stream.
     *             DEFAULT: No
     *
     * @return mixed  The text of the part, or a stream resource if 'stream'
     *                is true.
     */
    public function getBody($options = array())
    {
        if (!$this->_indices) {
            return $this->_message->toString(array(
                'headers' => true,
                'stream' => !empty($options['stream'])
            ));
        }

        $query = new Horde_Imap_Client_Fetch_Query();
        $query->bodytext(array(
            'peek' => true
        ));

        return ($res = $this->_fetchData($query))
            ? $res->getBodyText(0, !empty($options['stream']))
            : '';
    }

    /**
     * Gets the raw text for one section of the message.
     *
     * @param integer $id     The ID of the MIME part.
     * @param array $options  Additional options:
     *   - decode: (boolean) Attempt to decode the bodypart on the remote
     *             server.
     *             DEFAULT: No
     *   - length: (integer) If set, only download this many bytes of the
     *             bodypart from the server.
     *             DEFAULT: All data is retrieved.
     *   - mimeheaders: (boolean) Include the MIME headers also?
     *                  DEFAULT: No
     *   - stream: (boolean) If true, return a stream.
     *             DEFAULT: No
     *
     * @return object  Object with the following properties:
     * <pre>
     *   - data: (mixed) The text of the part or a stream resource if 'stream'
     *           option is true.
     *   - decode: (string) If 'decode' option is true, and bodypart decoded
     *             on server, the content-type of the decoded data.
     * </pre>
     */
    public function getBodyPart($id, $options = array())
    {
        $ret = new stdClass;
        $ret->data = '';
        $ret->decode = null;

        if (empty($id)) {
            return $ret;
        }

        if (!$this->_indices || $this->isEmbedded($id)) {
            if (empty($options['mimeheaders']) ||
                in_array($id, $this->_embedded)) {
                $ob = $this->getMIMEPart($id, array('nocontents' => true));

                if (empty($options['stream'])) {
                    if (!is_null($ob)) {
                        $ret->data = $ob->getContents();
                    }
                } else {
                    $ret->data = is_null($ob)
                        ? fopen('php://temp', 'r+')
                        : $ob->getContents(array('stream' => true));
                }

                return $ret;
            }

            $base_id = $id;
            while (!in_array($base_id, $this->_embedded, true)) {
                $base_id = Horde_Mime::mimeIdArithmetic($base_id, 'up');
                if (is_null($base_id)) {
                    return $ret;
                }
            }

            $part = $this->getMIMEPart($base_id, array('nocontents' => true));
            $txt = $part->addMimeHeaders()->toString() .
                "\n" .
                $part->getContents();

            try {
                $body = Horde_Mime_Part::getRawPartText($txt, 'header', '1') .
                    "\n\n" .
                    Horde_Mime_Part::getRawPartText($txt, 'body', '1');
            } catch (Horde_Mime_Exception $e) {
                $body = '';
            }

            if (empty($options['stream'])) {
                $ret->data = $body;
                return $ret;
            }

            $ret->data = fopen('php://temp', 'r+');
            if (strlen($body)) {
                fwrite($ret->data, $body);
                fseek($ret->data, 0);
            }
            return $ret;
        }

        $query = new Horde_Imap_Client_Fetch_Query();
        if (substr($id, -2) === '.0') {
            $rfc822 = true;
            $id = substr($id, 0, -2);
        } else {
            $rfc822 = false;
        }

        if (!isset($options['length']) || !empty($options['length'])) {
            $bodypart_params = array(
                'decode' => !empty($options['decode']),
                'peek' => true
            );

            if (isset($options['length'])) {
                $bodypart_params['start'] = 0;
                $bodypart_params['length'] = $options['length'];
            }

            if ($rfc822) {
                $bodypart_params['id'] = $id;
                $query->bodyText($bodypart_params);
            } else {
                $query->bodyPart($id, $bodypart_params);
            }
        }

        if (!empty($options['mimeheaders'])) {
            if ($rfc822) {
                $query->headerText(array(
                    'id' => $id,
                    'peek' => true
                ));
            } else {
                $query->mimeHeader($id, array(
                    'peek' => true
                ));
            }
        }

        if ($res = $this->_fetchData($query)) {
            try {
                if (empty($options['mimeheaders'])) {
                    $ret->decode = $res->getBodyPartDecode($id);
                    $ret->data = $rfc822
                        ? $res->getBodyText($id, !empty($options['stream']))
                        : $res->getBodyPart($id, !empty($options['stream']));
                    return $ret;
                } elseif (empty($options['stream'])) {
                    $ret->data = $rfc822
                        ? ($res->getHeaderText($id) . $res->getBodyText($id))
                        : ($res->getMimeHeader($id) . $res->getBodyPart($id));
                    return $ret;
                }

                if ($rfc822) {
                    $data = array(
                        $res->getHeaderText($id, Horde_Imap_Client_Data_Fetch::HEADER_STREAM),
                        $res->getBodyText($id, true)
                    );
                } else {
                    $data = array(
                        $res->getMimeHeader($id, Horde_Imap_Client_Data_Fetch::HEADER_STREAM),
                        $res->getBodyPart($id, true)
                    );
                }

                $ret->data = Horde_Stream_Wrapper_Combine::getStream($data);
                return $ret;
            } catch (Horde_Exception $e) {}
        }

        if (!empty($options['stream'])) {
            $ret->data = fopen('php://temp', 'r+');
        }

        return $ret;
    }

    /**
     * Returns the full message text.
     *
     * @param array $options  Additional options:
     *   - stream: (boolean) If true, return a stream for bodytext.
     *             DEFAULT: No
     *
     * @return mixed  The full message text or a stream resource if 'stream'
     *                is true.
     */
    public function fullMessageText($options = array())
    {
        if (!$this->_indices) {
            return $this->_message->toString();
        }

        $query = new Horde_Imap_Client_Fetch_Query();
        $query->bodyText(array(
            'peek' => true
        ));

        if ($res = $this->_fetchData($query)) {
            try {
                if (empty($options['stream'])) {
                    return $this->getHeader(self::HEADER_TEXT) . $res->getBodyText(0);
                }

                return Horde_Stream_Wrapper_Combine::getStream(array(
                    $this->getHeader(self::HEADER_STREAM),
                    $res->getBodyText(0, true)
                ));
            } catch (Horde_Exception $e) {}
        }

        return empty($options['stream'])
            ? ''
            : fopen('php://temp', 'r+');
    }

    /**
     * Returns base header information.
     *
     * @param integer $type  Return type (HEADER_* constant).
     *
     * @return mixed  Either a Horde_Mime_Headers object (HEADER_OB), header
     *                text (HEADER_TEXT), or a stream resource (HEADER_STREAM).
     */
    public function getHeader($type = self::HEADER_OB)
    {
        return $this->_getHeader($type, false);
    }

    /**
     * Returns base header information and marks the message as seen.
     *
     * @param integer $type  See getHeader().
     *
     * @return mixed  See getHeader().
     */
    public function getHeaderAndMarkAsSeen($type = self::HEADER_OB)
    {
        $mbox = $this->getMailbox();

        if ($mbox->readonly) {
            $seen = false;
        } else {
            $seen = true;

            if (isset($this->_header)) {
                try {
                    $imp_imap = $mbox->imp_imap;
                    $imp_imap->store($mbox, array(
                        'add' => array(
                            Horde_Imap_Client::FLAG_SEEN
                        ),
                        'ids' => $imp_imap->getIdsOb($this->getUid())
                    ));
                } catch (Exception $e) {}
            }
        }

        return $this->_getHeader($type, $seen);
    }

    /**
     * Returns base header information.
     *
     * @param integer $type  See getHeader().
     * @param boolean $seen  Mark message as seen?
     *
     * @return mixed  See getHeader().
     */
    protected function _getHeader($type, $seen)
    {
        if (!isset($this->_header)) {
            if (!$this->_indices) {
                $this->_header = $this->_message->addMimeHeaders();
            } else {
                $query = new Horde_Imap_Client_Fetch_Query();
                $query->headerText(array(
                    'peek' => !$seen
                ));

                $this->_header = ($res = $this->_fetchData($query))
                    ? $res
                    : new Horde_Imap_Client_Data_Fetch();
            }
        }

        switch ($type) {
        case self::HEADER_OB:
            return $this->_indices
                ? $this->_header->getHeaderText(0, Horde_Imap_Client_Data_Fetch::HEADER_PARSE)
                : $this->_header;

        case self::HEADER_TEXT:
            return $this->_indices
                ? $this->_header->getHeaderText()
                : $this->_header->toString();

        case self::HEADER_STREAM:
            if ($this->_indices) {
                return $this->_header->getHeaderText(0, Horde_Imap_Client_Data_Fetch::HEADER_STREAM);
            }

            $stream = new Horde_Support_StringStream($this->_header->toString());
            $stream->fopen();
            return $stream;
        }
    }

    /**
     * Returns the Horde_Mime_Part object.
     *
     * @return Horde_Mime_Part  A Horde_Mime_Part object.
     */
    public function getMIMEMessage()
    {
        return $this->_message;
    }

    /**
     * Fetch a part of a MIME message.
     *
     * @param integer $id     The MIME index of the part requested.
     * @param array $options  Additional options:
     *   - length: (integer) If set, only download this many bytes of the
     *             bodypart from the server.
     *             DEFAULT: All data is retrieved.
     *   - nocontents: (boolean) If true, don't add the contents to the part
     *                 DEFAULT: Contents are added to the part
     *
     * @return Horde_Mime_Part  The raw MIME part asked for (reference).
     */
    public function getMIMEPart($id, $options = array())
    {
        $this->_buildMessage();

        $part = $this->_message->getPart($id);

        /* Ticket #9201: Treat 'ISO-8859-1' as 'windows-1252'. 1252 has some
         * characters (e.g. euro sign, back quote) not in 8859-1. There
         * shouldn't be any issue doing this since the additional code points
         * in 1252 don't map to anything in 8859-1. */
        if ($part &&
            (strcasecmp($part->getCharset(), 'ISO-8859-1') === 0)) {
            $part->setCharset('windows-1252');
        }

        /* Don't download contents of entire body if ID == 0 (indicating the
         * body of the main multipart message).  I'm pretty sure we never
         * want to download the body of that part here. */
        if (!empty($id) &&
            !is_null($part) &&
            (substr($id, -2) != '.0') &&
            empty($options['nocontents']) &&
            $this->_indices &&
            !$part->getContents(array('stream' => true))) {
            $body = $this->getBodyPart($id, array(
                'decode' => true,
                'length' => empty($options['length']) ? null : $options['length'],
                'stream' => true
            ));
            $part->setContents($body->data, array(
                'encoding' => $body->decode,
                'usestream' => true
            ));
        }

        return $part;
    }

    /**
     * Render a MIME Part.
     *
     * @param string $mime_id  The MIME ID to render.
     * @param integer $mode    One of the RENDER_ constants.
     * @param array $options   Additional options:
     *   - autodetect: (boolean) Attempt to auto-detect MIME type?
     *   - mime_part: (Horde_Mime_Part) The MIME part to render.
     *   - type: (string) Use this MIME type instead of the MIME type
     *           identified in the MIME part.
     *
     * @return array  See Horde_Mime_Viewer_Base::render(). The following
     *                fields may also be present in addition to the fields
     *                defined in Horde_Mime_Viewer_Base:
     *   - attach: (boolean) Force display of this part as an attachment.
     *   - js: (array) A list of javascript commands to run after the content
     *         is displayed on screen.
     *   - name: (string) Contains the MIME name information.
     *   - wrap: (string) If present, indicates that this part, and all child
     *           parts, will be wrapped in a DIV with the given class name.
     */
    public function renderMIMEPart($mime_id, $mode, array $options = array())
    {
        $this->_buildMessage();

        $mime_part = empty($options['mime_part'])
            ? $this->getMIMEPart($mime_id)
            : $options['mime_part'];
        if (!$mime_part) {
            return array($mime_id => null);
        }

        if (!empty($options['autodetect']) &&
            ($tempfile = Horde::getTempFile()) &&
            ($fp = fopen($tempfile, 'w')) &&
            !is_null($contents = $mime_part->getContents(array('stream' => true)))) {
            rewind($contents);
            while (!feof($contents)) {
                fwrite($fp, fread($contents, 8192));
            }
            fclose($fp);

            $options['type'] = Horde_Mime_Magic::analyzeFile($tempfile, empty($GLOBALS['conf']['mime']['magic_db']) ? null : $GLOBALS['conf']['mime']['magic_db']);
        }

        $type = empty($options['type'])
            ? null
            : $options['type'];

        $viewer = $GLOBALS['injector']->getInstance('IMP_Factory_MimeViewer')->create($mime_part, array('contents' => $this, 'type' => $type));

        switch ($mode) {
        case self::RENDER_INLINE:
        case self::RENDER_INLINE_AUTO:
        case self::RENDER_INLINE_DISP_NO:
            $textmode = 'inline';
            $limit = $viewer->getConfigParam('limit_inline_size');

            if ($limit && ($mime_part->getBytes() > $limit)) {
                $data = '';
                $status = new IMP_Mime_Status(array(
                    _("This message part cannot be viewed because it is too large."),
                    sprintf(_("Click %s to download the data."), $this->linkView($mime_part, 'download_attach', _("HERE")))
                ));
                $status->icon('alerts/warning.png', _("Warning"));

                if (method_exists($viewer, 'overLimitText')) {
                    $data = $viewer->overLimitText();
                    $status->addText(_("The initial portion of this text part is displayed below."));
                }

                return array(
                    $mime_id => array(
                        'data' => $data,
                        'name' => '',
                        'status' => $status,
                        'type' => 'text/html; charset=' . 'UTF-8'
                    )
                );
            }
            break;

        case self::RENDER_INFO:
            $textmode = 'info';
            break;

        case self::RENDER_RAW:
            $textmode = 'raw';
            break;

        case self::RENDER_RAW_FALLBACK:
            $textmode = $viewer->canRender('raw')
                ? 'raw'
                : 'full';
            break;

        case self::RENDER_FULL:
        default:
            $textmode = 'full';
            break;
        }

        $ret = $viewer->render($textmode);

        if (empty($ret)) {
            return ($mode == self::RENDER_INLINE_AUTO)
                ? $this->renderMIMEPart($mime_id, self::RENDER_INFO, $options)
                : array();
        }

        if (!empty($ret[$mime_id]) && !isset($ret[$mime_id]['name'])) {
            $ret[$mime_id]['name'] = $mime_part->getName(true);
        }

        /* Don't show empty parts. */
        if (($textmode == 'inline') &&
            !is_null($ret[$mime_id]['data']) &&
            !strlen($ret[$mime_id]['data']) &&
            !isset($ret[$mime_id]['status'])) {
            $ret[$mime_id] = null;
        }

        return $ret;
    }

    /**
     * Finds the main "body" text part (if any) in a message.
     * "Body" data is the first text part in the base MIME part.
     *
     * @param string $subtype  Specifically search for this subtype.
     *
     * @return string  The MIME ID of the main body part.
     */
    public function findBody($subtype = null)
    {
        $this->_buildMessage();
        return $this->_message->findBody($subtype);
    }

    /**
     * Generate the preview text.
     *
     * @return array  Array with the following keys:
     *   - cut: (boolean) Was the preview text cut?
     *   - text: (string) The preview text.
     */
    public function generatePreview()
    {
        // For preview generation, don't go through overhead of scanning for
        // embedded parts. Necessary evil, or else very large parts (e.g
        // 5 MB+ text parts) will take ages to scan.
        $oldbuild = $this->_build;
        $this->_build = true;
        $mimeid = $this->findBody();

        if (is_null($mimeid)) {
            $this->_build = $oldbuild;
            return array('cut' => false, 'text' => '');
        }

        $maxlen = empty($GLOBALS['conf']['msgcache']['preview_size'])
            ? $GLOBALS['prefs']->getValue('preview_maxlen')
            : $GLOBALS['conf']['msgcache']['preview_size'];

        // Retrieve 3x the size of $maxlen of bodytext data. This should
        // account for any content-encoding & HTML tags.
        $pmime = $this->getMIMEPart($mimeid, array('length' => $maxlen * 3));

        $ptext = Horde_String::convertCharset($pmime->getContents(), $pmime->getCharset(), 'UTF-8');

        if ($pmime->getType() == 'text/html') {
            $ptext = $GLOBALS['injector']->getInstance('Horde_Core_Factory_TextFilter')->filter($ptext, 'Html2text');
        }

        $this->_build = $oldbuild;

        if (Horde_String::length($ptext) > $maxlen) {
            return array(
                'cut' => true,
                'text' => Horde_String::truncate($ptext, $maxlen)
            );
        }

        return array(
            'cut' => false,
            'text' => $ptext
        );
    }

    /**
     * Get summary info for a MIME ID.
     *
     * @param string $id     The MIME ID.
     * @param integer $mask  A bitmask indicating what information to return:
     * <pre>
     * Always output:
     *   'type' = MIME type
     *
     * IMP_Contents::SUMMARY_BYTES
     *   Output: parts = 'bytes'
     *
     * IMP_Contents::SUMMARY_SIZE
     *   Output: parts = 'size'
     *
     * IMP_Contents::SUMMARY_ICON
     * IMP_Contents::SUMMARY_ICON_RAW
     *   Output: parts = 'icon'
     *
     * IMP_Contents::SUMMARY_DESCRIP
     *   Output: parts = 'description_raw'
     *
     * IMP_Contents::SUMMARY_DESCRIP_LINK
     *   Output: parts = 'description'
     *
     * IMP_Contents::SUMMARY_DOWNLOAD
     *   Output: parts = 'download', 'download_url'
     *
     * IMP_Contents::SUMMARY_DOWNLOAD_ZIP
     *   Output: parts = 'download_zip'
     *
     * IMP_Contents::SUMMARY_IMAGE_SAVE
     *   Output: parts = 'img_save'
     *
     * IMP_Contents::SUMMARY_PRINT
     * IMP_Contents::SUMMARY_PRINT_STUB
     *   Output: parts = 'print'
     *
     * IMP_Contents::SUMMARY_STRIP
     *   Output: parts = 'strip'
     * </pre>
     *
     * @return array  An array with the requested information.
     */
    public function getSummary($id, $mask = 0)
    {
        $autodetect_link = false;
        $download_zip = (($mask & self::SUMMARY_DOWNLOAD_ZIP) && Horde_Util::extensionExists('zlib'));
        $param_array = array();

        $this->_buildMessage();

        $part = array(
            'bytes' => null,
            'download' => null,
            'download_url' => null,
            'download_zip' => null,
            'id' => $id,
            'img_save' => null,
            'size' => null,
            'strip' => null
        );

        $mime_part = $this->getMIMEPart($id, array('nocontents' => true));
        if (empty($mime_part)) {
            throw new IMP_Exception('MIME Part not found.');
        }
        $mime_type = $mime_part->getType();

        /* If this is an attachment that has no specific MIME type info, see
         * if we can guess a rendering type. */
        if (in_array($mime_type, array('application/octet-stream', 'application/base64'))) {
            $mime_type = Horde_Mime_Magic::filenameToMIME($mime_part->getName());
            if ($mime_type == $mime_part->getType()) {
                $autodetect_link = true;
            } else {
                $mime_part = clone $mime_part;
                $mime_part->setType($mime_type);
                $param_array['ctype'] = $mime_type;
            }
        }
        $part['type'] = $mime_type;

        /* Is this part an attachment? */
        $is_atc = $this->isAttachment($mime_type);

        /* Get bytes/size information. */
        if (($mask & self::SUMMARY_BYTES) ||
            $download_zip ||
            ($mask & self::SUMMARY_SIZE)) {
            $part['bytes'] = $size = $mime_part->getBytes();
            $part['size'] = ($size > 1048576)
                ? sprintf(_("%s MB"), IMP::numberFormat($size / 1048576, 1))
                : sprintf(_("%s KB"), max(round($size / 1024), 1));
        }

        /* Get part's icon. */
        if (($mask & self::SUMMARY_ICON) ||
            ($mask & self::SUMMARY_ICON_RAW)) {
            $part['icon'] = $GLOBALS['injector']->getInstance('IMP_Factory_MimeViewer')->getIcon($mime_type);
            if ($mask & self::SUMMARY_ICON) {
                $part['icon'] = Horde_Themes_Image::tag($part['icon'], array(
                    'attr' => array(
                        'title' => $mime_type
                    )
                ));
            }
        } else {
            $part['icon'] = null;
        }

        /* Get part's description. */
        $description = $this->getPartName($mime_part, true);

        if ($mask & self::SUMMARY_DESCRIP_LINK) {
            if (($can_d = $this->canDisplay($mime_part, self::RENDER_FULL)) ||
                $autodetect_link) {
                $part['description'] = $this->linkViewJS($mime_part, 'view_attach', htmlspecialchars($description), array('jstext' => sprintf(_("View %s"), $description), 'params' => array_filter(array_merge($param_array, array(
                    'autodetect' => !$can_d
                )))));
            } else {
                $part['description'] = htmlspecialchars($description);
            }
        }
        if ($mask & self::SUMMARY_DESCRIP) {
            $part['description_raw'] = $description;
        }

        /* Download column. */
        if (($mask & self::SUMMARY_DOWNLOAD) &&
            $is_atc &&
            (is_null($part['bytes']) || $part['bytes'])) {
            $part['download'] = $this->linkView($mime_part, 'download_attach', '', array('class' => 'iconImg downloadAtc', 'jstext' => _("Download")));
            $part['download_url'] = $this->urlView($mime_part, 'download_attach');
        }

        /* Display the compressed download link only if size is greater
         * than 200 KB. */
        if ($is_atc &&
            $download_zip &&
            ($part['bytes'] > 204800)) {
            $viewer = $GLOBALS['injector']->getInstance('IMP_Factory_MimeViewer')->create($mime_part, array('contents' => $this, 'type' => $mime_type));
            if (!$viewer->getMetadata('compressed')) {
                $part['download_zip'] = $this->linkView($mime_part, 'download_attach', null, array('class' => 'iconImg downloadZipAtc', 'jstext' => sprintf(_("Download %s in .zip Format"), $description), 'params' => array('zip' => 1)));
            }
        }

        /* Display the image save link if the required registry calls are
         * present. */
        if (($mask & self::SUMMARY_IMAGE_SAVE) &&
            $GLOBALS['registry']->hasMethod('images/selectGalleries') &&
            ($mime_part->getPrimaryType() == 'image')) {
            $part['img_save'] = Horde::link('#', _("Save Image in Gallery"), 'iconImg saveImgAtc', null, Horde::popupJs(IMP_Basic_Saveimage::url(), array('params' => array('muid' => strval($this->getIndicesOb()), 'id' => $id), 'height' => 200, 'width' => 450, 'urlencode' => true)) . 'return false;') . '</a>';
        }

        /* Add print link? */
        if ((($mask & self::SUMMARY_PRINT) ||
             ($mask & self::SUMMARY_PRINT_STUB)) &&
            $this->canDisplay($id, self::RENDER_FULL)) {
            $part['print'] = ($mask & self::SUMMARY_PRINT)
                ? $this->linkViewJS($mime_part, 'print_attach', '', array('css' => 'iconImg printAtc', 'jstext' => _("Print"), 'onload' => 'IMP_JS.printWindow', 'params' => $param_array))
                : Horde::link('#', _("Print"), 'iconImg printAtc', null, null, null, null, array('mimeid' => $id)) . '</a>';
        }

        /* Strip Attachment? Allow stripping of base parts other than the
         * base multipart and the base text (body) part. */
        if (($mask & self::SUMMARY_STRIP) &&
            ($id != 0) &&
            (intval($id) != 1) &&
            (strpos($id, '.') === false)) {
            $part['strip'] = Horde::link(
                Horde::selfUrlParams()->add(array(
                    'actionID' => 'strip_attachment',
                    'imapid' => $id,
                    'muid' => strval($this->getIndicesOb()),
                    'token' => $GLOBALS['session']->getToken()
                )),
                _("Strip Attachment"),
                'iconImg deleteImg stripAtc',
                null,
                null,
                null,
                null,
                array('mimeid' => $id)
            ) . '</a>';
        }

        return $part;
    }

    /**
     * Return the URL to the download/view page.
     *
     * @param Horde_Mime_Part $mime_part  The MIME part to view.
     * @param integer $actionID           The actionID to perform.
     * @param array $options              Additional options:
     *   - params: (array) A list of any additional parameters that need to be
     *             passed to the download/view page (key => name).
     *
     * @return Horde_Url  The URL to the download/view page.
     */
    public function urlView($mime_part = null, $actionID = 'view_attach',
                            array $options = array())
    {
        $params = $this->_urlViewParams($mime_part, $actionID, isset($options['params']) ? $options['params'] : array());

        return (strpos($actionID, 'download_') === 0)
            ? IMP_Contents_View::downloadUrl($mime_part->getName(true), $params)
            : Horde::url('view.php', true)->add($params);
    }

    /**
     * Generates the necessary URL parameters for the download/view page.
     *
     * @param Horde_Mime_Part $mime_part  The MIME part to view.
     * @param integer $actionID           The actionID to perform.
     * @param array $params               Additional parameters to pass.
     *
     * @return array  The array of parameters.
     */
    protected function _urlViewParams($mime_part, $actionID, $params)
    {
        /* Add the necessary local parameters. */
        $params = array_merge($params, array(
            'actionID' => $actionID,
            'id' => isset($params['id']) ? $params['id'] : $mime_part->getMIMEId()
        ));

        if ($this->_indices) {
            $params['muid'] = strval($this->getIndicesOb());
        }

        return IMP_Contents_View::addToken($params);
    }

    /**
     * Generate a link to the download/view page.
     *
     * @param Horde_Mime_Part $mime_part  The MIME part to view.
     * @param integer $actionID           The actionID value.
     * @param string $text                The ESCAPED (!) link text.
     * @param array $options              Additional parameters:
     *   - class: (string) The CSS class to use.
     *   - jstext: (string) The JS text to use.
     *   - params: (array) A list of any additional parameters that need to be
     *             passed to the download/view page.
     *
     * @return string  A HTML href link to the download/view page.
     */
    public function linkView($mime_part, $actionID, $text, $options = array())
    {
        $options = array_merge(array(
            'class' => null,
            'jstext' => $text,
            'params' => array()
        ), $options);

        return Horde::link(
            $this->urlView($mime_part, $actionID, $options),
            $options['jstext'],
            $options['class'],
            ($actionID == 'download_attach') ? null : strval(new Horde_Support_Randomid())
        ) . $text . '</a>';
    }

    /**
     * Generate a javascript link to the download/view page.
     *
     * @param Horde_Mime_Part $mime_part  The MIME part to view.
     * @param string $actionID            The actionID to perform.
     * @param string $text                The ESCAPED (!) link text.
     * @param array $options              Additional options:
     *   - css: (string) The CSS class to use.
     *   - jstext: (string) The javascript link text.
     *   - onload: (string) A JS function to run when popup window is
     *             fully loaded.
     *   - params: (array) A list of any additional parameters that need to be
     *             passed to download/view page. (key = name)
     *   - widget: (boolean) If true use Horde::widget() to generate,
     *             Horde::link() otherwise.
     *
     * @return string  A HTML href link to the download/view page.
     */
    public function linkViewJS($mime_part, $actionID, $text,
                               $options = array())
    {
        if (empty($options['params'])) {
            $options['params'] = array();
        }

        if (empty($options['jstext'])) {
            $options['jstext'] = sprintf(_("View %s"), $mime_part->getDescription(true));
        }

        $url = Horde::popupJs(Horde::url('view.php'), array(
            'menu' => true,
            'onload' => empty($options['onload']) ? 'IMP_JS.resizePopup' : $options['onload'],
            'params' => $this->_urlViewParams($mime_part, $actionID, isset($options['params']) ? $options['params'] : array()),
            'urlencode' => true
        ));

        return empty($options['widget'])
            ? Horde::link('#', $options['jstext'], empty($options['css']) ? null : $options['css'], null, $url) . $text . '</a>'
            : Horde::widget(array('url' => '#', 'class' => empty($options['css']) ? null : $options['css'], 'onclick' => $url, 'title' => $text));
    }

    /**
     * Determines if a MIME type is an attachment.
     * For IMP's purposes, an attachment is any MIME part that can be
     * downloaded by itself (i.e. all the data needed to view the part is
     * contained within the download data).
     *
     * @param string $mime_type  The MIME type.
     *
     * @return boolean  True if an attachment.
     */
    public function isAttachment($mime_type)
    {
        switch ($mime_type) {
        case 'application/ms-tnef':
            return false;
        }

        list($ptype,) = explode('/', $mime_type, 2);

        switch ($ptype) {
        case 'message':
            return in_array($mime_type, array('message/rfc822', 'message/disposition-notification'));

        case 'multipart':
            return false;

        default:
            return true;
        }
    }

    /**
     * Builds the "virtual" Horde_Mime_Part object by checking for embedded
     * parts.
     *
     * @param array $parts  The parts list to process.
     */
    protected function _buildMessage($parts = null)
    {
        global $injector;

        if (is_null($parts)) {
            if ($this->_build) {
                return;
            }
            $this->_build = true;
            $parts = array_keys($this->_message->contentTypeMap());
            $first_id = reset($parts);
        } else {
            $first_id = null;
        }

        $last_id = null;
        $to_process = array();

        $mv_factory = $injector->getInstance('IMP_Factory_MimeViewer');

        foreach ($parts as $id) {
            if (!is_null($last_id) &&
                (strpos($id, $last_id) === 0)) {
                continue;
            }

            $last_id = null;

            $mime_part = $this->getMIMEPart($id, array('nocontents' => true));
            $viewer = $mv_factory->create($mime_part, array('contents' => $this));
            if ($viewer->embeddedMimeParts()) {
                $mime_part = $this->getMIMEPart($id);
                $viewer->setMIMEPart($mime_part);
                $new_part = $viewer->getEmbeddedMimeParts();
                if (!is_null($new_part)) {
                    $mime_part->addPart($new_part);
                    $mime_part->buildMimeIds($id);
                    $this->_embedded[] = $new_part->getMimeId();
                    $to_process = array_merge($to_process, array_keys($new_part->contentTypeMap()));
                    $last_id = $id;
                }
            }
        }

        if (!empty($to_process)) {
            $this->_buildMessage($to_process);
        }
    }

    /**
     * Can this MIME part be displayed in the given mode?
     *
     * @param mixed $part    The MIME part or a MIME ID string.
     * @param integer $mask  One of the RENDER_ constants.
     * @param string $type   The type to use (overrides the MIME ID if $id is
     *                       a MIME part).
     *
     * @return integer  The RENDER_ constant of the allowable display.
     */
    public function canDisplay($part, $mask, $type = null)
    {
        if (!is_object($part)) {
            $part = $this->getMIMEPart($part, array('nocontents' => true));
        }
        if (!$part) {
            return 0;
        }
        $viewer = $GLOBALS['injector']->getInstance('IMP_Factory_MimeViewer')->create($part, array('contents' => $this, 'type' => $type));

        if ($mask & self::RENDER_INLINE_AUTO) {
            $mask |= self::RENDER_INLINE | self::RENDER_INFO;
        }

        if (($mask & self::RENDER_RAW) && $viewer->canRender('raw')) {
            return self::RENDER_RAW;
        }

        if (($mask & self::RENDER_FULL) && $viewer->canRender('full')) {
            return self::RENDER_FULL;
        }

        if ($mask & self::RENDER_INLINE) {
            if ($viewer->canRender('inline')) {
                return self::RENDER_INLINE;
            }
        } elseif (($mask & self::RENDER_INLINE_DISP_NO) &&
                  $viewer->canRender('inline')) {
            return self::RENDER_INLINE_DISP_NO;
        }

        if (($mask & self::RENDER_INFO) && $viewer->canRender('info')) {
            return self::RENDER_INFO;
        }

        return 0;
    }

    /**
     * Returns the Content-Type map for the entire message, regenerating
     * embedded parts if needed.
     *
     * @return array  See Horde_Mime_Part::contentTypeMap().
     */
    public function getContentTypeMap()
    {
        $this->_buildMessage();
        return $this->_message->contentTypeMap();
    }

    /**
     * Returns the MIME part tree of the message.
     *
     * @param string $renderer  Either the tree renderer driver or a full
     *                          class name to use.
     *
     * @return Horde_Tree_Renderer_Base  A tree instance representing the MIME parts.
     * @throws Horde_Tree_Exception
     */
    public function getTree($renderer = 'Horde_Core_Tree_Renderer_Html')
    {
        $tree = $GLOBALS['injector']->getInstance('Horde_Core_Factory_Tree')->create('mime-' . $this->getUid(), $renderer, array(
            'nosession' => true
        ));
        $this->_addTreeNodes($tree, $this->_message);
        return $tree;
    }

    /**
     * Adds MIME parts to the tree instance.
     *
     * @param Horde_Tree_Renderer_Base tree   A tree instance.
     * @param Horde_Mime_Part $part           The MIME part to add to the
     *                                        tree, including its sub-parts.
     * @param string $parent                  The parent part's MIME id.
     */
    protected function _addTreeNodes($tree, $part, $parent = null)
    {
        $mimeid = $part->getMimeId();

        $summary_mask = self::SUMMARY_ICON_RAW | self::SUMMARY_DESCRIP_LINK | self::SUMMARY_SIZE | self::SUMMARY_DOWNLOAD;
        if ($GLOBALS['prefs']->getValue('strip_attachments')) {
            $summary_mask += self::SUMMARY_STRIP;
        }

        $summary = $this->getSummary($mimeid, $summary_mask);

        $tree->addNode(array(
            'id' => $mimeid,
            'parent' => $parent,
            'label' => sprintf(
                '%s (%s) %s %s',
                $summary['description'],
                $summary['size'],
                $summary['download'],
                $summary['strip']
            ),
            'params' => array(
                'class' => 'partsTreeDiv',
                'icon' => $summary['icon']
            )
        ));

        foreach ($part->getParts() as $part) {
            $this->_addTreeNodes($tree, $part, $mimeid);
        }
    }

    /**
     * Get download all list.
     *
     * @return array  An array of downloadable parts.
     */
    public function downloadAllList()
    {
        $ret = array();

        foreach ($this->getContentTypeMap() as $key => $val) {
            if ($this->isAttachment($val)) {
                $ret[] = $key;
            }
        }

        return $ret;
    }

    /**
     * Injects body contents into the base Horde_Mime_part object.
     *
     * @param array $ignore  A list of MIME IDs to ignore.
     *
     * @return Horde_Mime_Part  The part with body contents set.
     */
    public function buildMessageContents($ignore = array())
    {
        $message = $this->_message;
        $curr_ignore = null;

        foreach ($message->contentTypeMap() as $key => $val) {
            if (is_null($curr_ignore) && in_array($key, $ignore)) {
                $curr_ignore = $key . '.';
            } elseif (is_null($curr_ignore) ||
                      (strpos($key, $curr_ignore) === false)) {
                $curr_ignore = null;
                if (($key != 0) &&
                    ($val != 'message/rfc822') &&
                    (strpos($val, 'multipart/') === false)) {
                    $part = $this->getMIMEPart($key);
                    $message->alterPart($key, $part);
                }
            }
        }

        return $message;
    }

    /**
     * Determines if a given MIME part ID is a part of embedded data.
     *
     * @param string $mime_id  The MIME ID.
     *
     * @return boolean  True if the MIME ID is part of embedded data.
     */
    public function isEmbedded($mime_id)
    {
        foreach ($this->_embedded as $val) {
            if (($mime_id == $val) || Horde_Mime::isChild($val, $mime_id)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Find a MIME type in parent parts.
     *
     * @param string $id    The MIME ID to begin the search at.
     * @param string $type  The MIME type to search for.
     *
     * @return mixed  Either the requested MIME part, or null if not found.
     */
    public function findMimeType($id, $type)
    {
        $id = Horde_Mime::mimeIdArithmetic($id, 'up');

        while (!is_null($id)) {
            if (($part = $this->getMIMEPart($id, array('nocontents' => true))) &&
                ($part->getType() == $type)) {
                return $part;
            }
            $id = Horde_Mime::mimeIdArithmetic($id, 'up');
        }

        return null;
    }

    /**
     * Return the descriptive part label, making sure it is not empty.
     *
     * @param Horde_Mime_Part $part  The MIME Part object.
     * @param boolean $use_descrip   Use description? If false, uses name.
     *
     * @return string  The part label (non-empty).
     */
    public function getPartName(Horde_Mime_Part $part, $use_descrip = false)
    {
        $name = $use_descrip
            ? $part->getDescription(true)
            : $part->getName(true);

        if ($name) {
            return $name;
        }

        switch ($ptype = $part->getPrimaryType()) {
        case 'multipart':
            if (($part->getSubType() == 'related') &&
                ($view_id = $part->getMetaData('viewable_part')) &&
                ($viewable = $this->getMIMEPart($view_id, array('nocontents' => true)))) {
                return $this->getPartName($viewable, $use_descrip);
            }
            /* Fall-through. */

        case 'application':
        case 'model':
            $ptype = $part->getSubType();
            break;
        }

        switch ($ptype) {
        case 'audio':
            return _("Audio");

        case 'image':
            return _("Image");

        case 'message':
        case '':
        case Horde_Mime_Part::UNKNOWN:
            return _("Message");

        case 'multipart':
            return _("Multipart");

        case 'text':
            return _("Text");

        case 'video':
            return _("Video");

        default:
            // Attempt to translate this type, if possible. Odds are that
            // it won't appear in the dictionary though.
            return _(Horde_String::ucfirst($ptype));
        }
    }

    /**
     * Generate inline message display.
     *
     * @param array $options  Options:
     *   - display_mask: (integer) The mask of display view type to render
     *                   inline (DEFAULT: RENDER_INLINE_AUTO).
     *   - mask: (integer) The mask needed for a getSummary() call.
     *   - no_inline_all: (boolean) If true, only display first inline part.
     *                    Subsequent inline parts will be treated as
     *                    attachments.
     *   - part_info_display: (array) The list of summary fields to display.
     *   - show_parts: (string) The value of the 'parts_display' pref.
     *
     * @return array  An array with the following keys:
     *   - atc_parts: (array) The list of attachment MIME IDs.
     *   - display_ids: (array) The list of display MIME IDs.
     *   - js_onload: (array) A list of javascript code to run onload.
     *   - msgtext: (string) The rendered HTML code.
     *   - one_part: (boolean) If true, the message only consists of one part.
     */
    public function getInlineOutput(array $options = array())
    {
        global $prefs, $registry;

        $atc_parts = $display_ids = $msgtext = $js_onload = $wrap_ids = array();
        $parts_list = $this->getContentTypeMap();
        $text_out = '';
        $view = $registry->getView();

        $contents_mask = isset($options['mask'])
            ? $options['mask']
            : 0;
        $display_mask = isset($options['display_mask'])
            ? $options['display_mask']
            : self::RENDER_INLINE_AUTO;
        $no_inline_all = !empty($options['no_inline_all']);
        $part_info_display = isset($options['part_info_display'])
            ? $options['part_info_display']
            : array();
        $show_parts = isset($options['show_parts'])
            ? $options['show_parts']
            : $prefs->getValue('parts_display');

        foreach ($parts_list as $mime_id => $mime_type) {
            if (isset($display_ids[$mime_id]) ||
                isset($atc_parts[$mime_id])) {
                continue;
            }

            if (!($render_mode = $this->canDisplay($mime_id, $display_mask))) {
                if ($this->isAttachment($mime_type)) {
                    if ($show_parts == 'atc') {
                        $atc_parts[$mime_id] = 1;
                    }

                    if ($contents_mask) {
                        $msgtext[$mime_id] = array(
                            'text' => $this->_formatSummary($mime_id, $contents_mask, $part_info_display, true)
                        );
                    }
                }
                continue;
            }

            $render_part = $this->renderMIMEPart($mime_id, $render_mode);
            if (($show_parts == 'atc') &&
                $this->isAttachment($mime_type) &&
                (empty($render_part) ||
                 !($render_mode & self::RENDER_INLINE))) {
                $atc_parts[$mime_id] = 1;
            }

            if (empty($render_part)) {
                if ($contents_mask &&
                    $this->isAttachment($mime_type)) {
                    $msgtext[$mime_id] = array(
                        'text' => $this->_formatSummary($mime_id, $contents_mask, $part_info_display, true)
                    );
                }
                continue;
            }

            reset($render_part);
            while (list($id, $info) = each($render_part)) {
                $display_ids[$id] = 1;

                if (empty($info)) {
                    continue;
                }

                if ($no_inline_all === 1) {
                    $atc_parts[$id] = 1;
                    continue;
                }

                $part_text = ($contents_mask && empty($info['nosummary']))
                    ? $this->_formatSummary($id, $contents_mask, $part_info_display, !empty($info['attach']))
                    : '';

                if (empty($info['attach'])) {
                    if (isset($info['status'])) {
                        if (!is_array($info['status'])) {
                            $info['status'] = array($info['status']);
                        }

                        foreach ($info['status'] as $val) {
                            if (in_array($view, $val->views)) {
                                $part_text .= strval($val);
                            }
                        }
                    }

                    $part_text .= '<div class="mimePartData">' . $info['data'] . '</div>';
                } elseif ($show_parts == 'atc') {
                    $atc_parts[$id] = 1;
                }

                $msgtext[$id] = array(
                    'text' => $part_text,
                    'wrap' => empty($info['wrap']) ? null : $info['wrap']
                );

                if (isset($info['js'])) {
                    $js_onload = array_merge($js_onload, $info['js']);
                }

                if ($no_inline_all) {
                    $no_inline_all = 1;
                }
            }
        }

        if (!empty($msgtext)) {
            uksort($msgtext, 'strnatcmp');
        }

        reset($msgtext);
        while (list($id, $part) = each($msgtext)) {
            while (!empty($wrap_ids) &&
                   !Horde_Mime::isChild(end($wrap_ids), $id)) {
                array_pop($wrap_ids);
                $text_out .= '</div>';
            }

            if (!empty($part['wrap'])) {
                $text_out .= '<div class="' . $part['wrap'] . '">';
                $wrap_ids[] = $id;
            }

            $text_out .= '<div class="mimePartBase">' . $part['text'] . '</div>';
        }

        $text_out .= str_repeat('</div>', count($wrap_ids));

        if (!strlen($text_out)) {
            $text_out = strval(new IMP_Mime_Status(_("There are no parts that can be shown inline.")));
        }

        $atc_parts = ($show_parts == 'all')
            ? array_keys($parts_list)
            : array_keys($atc_parts);

        return array(
            'atc_parts' => $atc_parts,
            'display_ids' => array_keys($display_ids),
            'js_onload' => $js_onload,
            'msgtext' => $text_out,
            'one_part' => (count($parts_list) == 1)
        );
    }

    /**
     * Prints out a MIME summary (in HTML).
     *
     * @param string $id      The MIME ID.
     * @param integer $mask   A bitmask indicating what summary information to
     *                        return.
     * @param array $display  The fields to display (in this order).
     * @param boolean $atc    Is this an attachment?
     *
     * @return string  The formatted summary string.
     */
    protected function _formatSummary($id, $mask, $display, $atc = false)
    {
        $summary = $this->getSummary($id, $mask);
        $tmp_summary = array();

        foreach ($display as $val) {
            if (isset($summary[$val])) {
                switch ($val) {
                case 'description':
                    $summary[$val] = '<span class="mimePartInfoDescrip">' . $summary[$val] . '</span>';
                    break;

                case 'size':
                    $summary[$val] = '<span class="mimePartInfoSize">(' . $summary[$val] . ')</span>';
                    break;
                }
                $tmp_summary[] = $summary[$val];
            }
        }

        return '<div class="mimePartInfo' .
            ($atc ? ' mimePartInfoAtc' : '') .
            '"><div>' .
            implode(' ', $tmp_summary) .
            '</div></div>';
    }

    /**
     * Get FETCH data from IMAP server for this message.
     *
     * @param Horde_Imap_Client_Fetch_Query $query  Search query.
     *
     * @return Horde_Imap_Client_Data_Fetch  Fetch data for the message.
     */
    protected function _fetchData(Horde_Imap_Client_Fetch_Query $query)
    {
        try {
            $mbox = $this->getMailbox();
            $imp_imap = $mbox->imp_imap;
            return $imp_imap->fetch($mbox, $query, array(
                'ids' => $imp_imap->getIdsOb($this->getUid())
            ))->first();
        } catch (Horde_Imap_Client_Exception $e) {
            return new Horde_Imap_Client_Data_Fetch();
        }
    }

    /**
     * Return the view cache object for this message.
     *
     * @return object  View object.
     */
    public function getViewCache()
    {
        if (!isset($this->_viewcache)) {
            $this->_viewcache = new stdClass;
        }

        return $this->_viewcache;
    }

}
