<?php

/**
 * Note this class has been modified to add the following methods:
 *   countMessages()
 * The following methods have been modified
 *   parseHeaders 
 *     -bcc field is parsed
 *   handlePart 
 *     -inline text/plain is added to the text element
 */

/**
 * Retrieves and deletes messages from a email account via IMAP or POP3
 *
 * All headers, text and html content returned by this class are encoded in
 * UTF-8. Please see http://flourishlib.com/docs/UTF-8 for more information.
 *
 * @copyright  Copyright (c) 2010-2012 Will Bond
 * @author     Will Bond [wb] <will@flourishlib.com>
 * @license    http://flourishlib.com/license
 *
 * @package    Flourish
 * @link       http://flourishlib.com/fMailbox
 *
 * @version    1.0.0b18
 * @changes    1.0.0b18  Fixed a bug in ::fetchMessageSource() where IMAP connections would add an extra `\r\n` to the end of the source [wb, 2012-09-16]
 * @changes    1.0.0b17  Updated the class to be more forgiving when parsing the response for `STATUS` and `FETCH` IMAP commands [wb, 2012-09-15]
 * @changes    1.0.0b16  Added method ::fetchMessageSource() [wb, 2012-09-15]
 * @changes    1.0.0b15  Fixed handling of bounces with no headers [wb, 2012-09-15]
 * @changes    1.0.0b14  Added a workaround for iconv having issues in MAMP 1.9.4+ [wb, 2011-07-26]
 * @changes    1.0.0b13  Fixed handling of headers in relation to encoded-words being embedded inside of quoted strings [wb, 2011-07-26]
 * @changes    1.0.0b12  Enhanced the error checking in ::write() [wb, 2011-06-03]
 * @changes    1.0.0b11  Added code to work around PHP bug #42682 (http://bugs.php.net/bug.php?id=42682) where `stream_select()` doesn't work on 64bit machines from PHP 5.2.0 to 5.2.5, improved connectivity error handling and timeouts while reading data [wb, 2011-01-10]
 * @changes    1.0.0b10  Fixed ::parseMessage() to properly handle a header format edge case and properly set the `text` and `html` keys even when the email has an explicit `Content-disposition: inline` header [wb, 2010-11-25]
 * @changes    1.0.0b9   Fixed a bug in ::parseMessage() that could cause HTML alternate content to be included in the `inline` content array instead of the `html` element [wb, 2010-09-20]
 * @changes    1.0.0b8   Fixed ::parseMessage() to be able to handle non-text/non-html multipart parts that do not have a `Content-disposition` header [wb, 2010-09-18]
 * @changes    1.0.0b7   Fixed a typo in ::read() [wb, 2010-09-07]
 * @changes    1.0.0b6   Fixed a typo from 1.0.0b4 [wb, 2010-07-21]
 * @changes    1.0.0b5   Fixes for increased compatibility with various IMAP and POP3 servers, hacked around a bug in PHP 5.3 on Windows [wb, 2010-06-22]
 * @changes    1.0.0b4   Added code to handle emails without an explicit `Content-type` header [wb, 2010-06-04]
 * @changes    1.0.0b3   Added missing static method callback constants [wb, 2010-05-11]
 * @changes    1.0.0b2   Added the missing ::enableDebugging() [wb, 2010-05-05]
 * @changes    1.0.0b    The initial implementation [wb, 2010-05-05]
 */
class fMailbox {

    const addSMIMEPair = 'fMailbox::addSMIMEPair';
    const parseMessage = 'fMailbox::parseMessage';
    const reset = 'fMailbox::reset';

    /**
     * S/MIME certificates and private keys for verification and decryption
     *
     * @var array
     */
    static private $smime_pairs = array();

    /**
     * Adds an S/MIME certificate, or certificate + private key pair for verification and decryption of S/MIME messages
     *
     * @param string       $email_address         The email address the certificate or private key is for
     * @param fFile|string $certificate_file      The file the S/MIME certificate is stored in - required for verification and decryption
     * @param fFile        $private_key_file      The file the S/MIME private key is stored in - required for decryption only
     * @param string       $private_key_password  The password for the private key
     * @return void
     */
    static public function addSMIMEPair($email_address, $certificate_file, $private_key_file = NULL, $private_key_password = NULL) {
        if ($private_key_file !== NULL && !$private_key_file instanceof fFile) {
            $private_key_file = new fFile($private_key_file);
        }
        if (!$certificate_file instanceof fFile) {
            $certificate_file = new fFile($certificate_file);
        }
        self::$smime_pairs[strtolower($email_address)] = array(
            'certificate' => $certificate_file,
            'private_key' => $private_key_file,
            'password' => $private_key_password
        );
    }

    /**
     * Takes a date, removes comments and cleans up some common formatting inconsistencies
     *
     * @param string $date  The date to clean
     * @return string  The cleaned date
     */
    static private function cleanDate($date) {
        $date = preg_replace('#\([^)]+\)#', ' ', trim($date));
        $date = preg_replace('#\s+#', ' ', $date);
        $date = preg_replace('#(\d+)-([a-z]+)-(\d{4})#i', '\1 \2 \3', $date);
        $date = preg_replace('#^[a-z]+\s*,\s*#i', '', trim($date));
        return trim($date);
    }

    /**
     * Decodes encoded-word headers of any encoding into raw UTF-8
     *
     * @param string $text  The header value to decode
     * @return string  The decoded UTF-8
     */
    static public function decodeHeader($text) {
        //DebugEcho("decodeHeader: start");
        $parts = preg_split('#(=\?[^\?]+\?[QB]\?[^\?]+\?=)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
        //DebugEcho("decodeHeader: parts");
        //DebugDump($parts);
        $part_with_encoding = array();
        $output = '';
        $islastencoded = false;
        foreach ($parts as $part) {
            if ($part === '') {
                continue;
            }

            if (preg_match_all('#=\?([^\?]+)\?([QB])\?([^\?]+)\?=#i', $part, $matches, PREG_SET_ORDER)) {
                //DebugEcho("decodeHeader: found encoded value: '$part'");
                //DebugDump($matches);
                $islastencoded = true;
                foreach ($matches as $match) {
                    if (strtoupper($match[2]) == 'Q') {
                        $part_string = rawurldecode(strtr(
                                        $match[3], array(
                            '=' => '%',
                            '_' => ' '
                                        )
                        ));
                        //DebugEcho("decodeHeader: Q encoding: '$part_string'");
                    } else {
                        $part_string = base64_decode($match[3]);
                        //DebugEcho("decodeHeader: B encoding: '$part_string'");
                    }
                    $lower_encoding = strtolower($match[1]);
                    $last_key = count($part_with_encoding) - 1;
                    $part_with_encoding[] = array('encoding' => $lower_encoding, 'string' => $part_string, 'encoded' => 1);
                }
                //DebugDump($part_with_encoding);
            } else {
                //DebugEcho("decodeHeader: found non-encoded value: '$part'");

                if (!($islastencoded && $part == '')) {
                    $last_key = count($part_with_encoding) - 1;
                    $part_with_encoding[] = array('encoding' => 'iso-8859-1', 'string' => $part, 'encoded' => 0);
                }
            }
        }

        //DebugEcho("decodeHeader: convert encoding");
        //DebugDump($part_with_encoding);
        if (count($part_with_encoding) > 0) {
            $output = self::iconv($part_with_encoding[0]['encoding'], 'UTF-8', $part_with_encoding[0]['string']);
            $i = 1;
            while ($i <= count($part_with_encoding) - 1) {
                if (count($part_with_encoding) - 1 > $i) {
                    $p = trim($part_with_encoding[$i]['string']);
                    if (empty($p) && $part_with_encoding[$i - 1]['encoded'] == 1 && $part_with_encoding[$i + 1]['encoded'] == 1) {
                        //noop
                    } else {
                        $output .= self::iconv($part_with_encoding[$i]['encoding'], 'UTF-8', $part_with_encoding[$i]['string']);
                    }
                } else {
                    $output .= self::iconv($part_with_encoding[$i]['encoding'], 'UTF-8', $part_with_encoding[$i]['string']);
                }
                $i++;
            }
        }

        return trim($output);
    }

    /**
     * Handles an individual part of a multipart message
     *
     * @param array  $info       An array of information about the message
     * @param array  $structure  An array describing the structure of the message
     * @return array  The modified $info array
     */
    static private function handlePart($info, $structure) {
        DebugEcho('handlePart: type ' . $structure['type'] . '/' . $structure['subtype']);
        if ($structure['type'] == 'multipart') {
            foreach ($structure['parts'] as $part) {
                $info = self::handlePart($info, $part);
            }
            return $info;
        }

        if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-mime', 'x-pkcs7-mime'))) {
            //DebugEcho('handlePart: secure1 ' . $structure['subtype']);
            $to = NULL;
            if (isset($info['headers']['to'][0])) {
                $to = $info['headers']['to'][0]['mailbox'];
                if (!empty($info['headers']['to'][0]['host'])) {
                    $to .= '@' . $info['headers']['to'][0]['host'];
                }
            }
            if ($to && !empty(self::$smime_pairs[$to]['private_key'])) {
                if (self::handleSMIMEDecryption($info, $structure, self::$smime_pairs[$to])) {
                    return $info;
                }
            }
        }

        if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-signature', 'x-pkcs7-signature'))) {
            //DebugEcho('handlePart: secure2 ' . $structure['subtype']);
            $from = NULL;
            if (isset($info['headers']['from'])) {
                $from = $info['headers']['from']['mailbox'];
                if (!empty($info['headers']['from']['host'])) {
                    $from .= '@' . $info['headers']['from']['host'];
                }
            }
            if ($from && !empty(self::$smime_pairs[$from]['certificate'])) {
                if (self::handleSMIMEVerification($info, $structure, self::$smime_pairs[$from])) {
                    return $info;
                }
            }
        }

        $data = $structure['data'];

        if ($structure['encoding'] == 'base64') {
            //DebugEcho('handlePart: base64');
            $content = '';
            foreach (explode("\r\n", $data) as $line) {
                $content .= base64_decode($line);
            }
        } elseif ($structure['encoding'] == 'quoted-printable') {
            //DebugEcho('handlePart: quoted-printable');
            $content = quoted_printable_decode($data);
        } else {
            //DebugEcho('handlePart: no encoding');
            $content = $data;
        }

        $filename = '';
        $has_disposition = !empty($structure['disposition']); // || !empty($structure['type_fields']);

        if ($has_disposition || !empty($structure['type_fields'])) {
            DebugEcho('handlePart: has_disposition ' . $structure['disposition']);
            //ensure the mime type is correct before doing additional decoding. Some mail clients mark all attachments as text/plain regardless of real type - looking at you outlook.com
            foreach ($structure['disposition_fields'] as $field => $value) {
                if (strtolower($field) == 'filename') {
                    //DebugEcho("handlePart: disposition_fields filename: $value");
                    $filename = $value;
                    $ftype = wp_check_filetype($filename);
                    if ($ftype['type']) {
                        //DebugEcho("handlePart: revised mimetype: {$ftype['type']}");
                        $mimes = explode('/', $ftype['type']);
                        $structure['type'] = $mimes[0];
                        $structure['subtype'] = $mimes[1];
                    }
                    break;
                }
            }
            foreach ($structure['type_fields'] as $field => $value) {
                if (strtolower($field) == 'name') {
                    //DebugEcho("handlePart: type_fields name: $value");
                    $filename = $value;
                    $ftype = wp_check_filetype($filename);
                    if ($ftype['type']) {
                        //DebugEcho("handlePart: revised mimetype: {$ftype['type']}");
                        $mimes = explode('/', $ftype['type']);
                        $structure['type'] = $mimes[0];
                        $structure['subtype'] = $mimes[1];
                    }
                    break;
                }
            }
        } else {
            DebugEcho('handlePart: no disposition');
        }

        if ($structure['type'] == 'text') {
            //DebugDump($structure);
            $charset = 'iso-8859-1';
            foreach ($structure['type_fields'] as $field => $value) {
                if (strtolower($field) == 'charset') {
                    $charset = $value;
                    //DebugEcho("handlePart: charset $value detected");
                    break;
                }
            }

            if (strtolower($charset) !== 'utf-8') {
                //DebugEcho("handlePart: converting $charset to UTF-8");
                $content = self::iconv($charset, 'UTF-8', $content);
            } else {
                //DebugEcho("handlePart: skipping charset conversion");
            }

            if ($structure['subtype'] == 'html') {
                //DebugEcho("handlePart: fixing up html");
                $content = preg_replace('#(content=(["\'])text/html\s*;\s*charset=(["\']?))' . preg_quote($charset, '#') . '(\3\2)#i', '\1utf-8\4', $content);
            }
            DebugEcho("handlePart: text length: " . strlen($content));
            //DebugDump($content);
        }

        // This indicates a content-id which is used for multipart/related
        if ($structure['content_id'] && $structure['type'] != 'text') {
            //DebugEcho('handlePart: multipart/related: ' . $structure['content_id']);
            if (!isset($info['related'])) {
                $info['related'] = array();
            }
            $cid = $structure['content_id'][0] == '<' ? substr($structure['content_id'], 1, -1) : $structure['content_id'];
            //DebugEcho("handlePart: cid: $cid ($filename) {$structure['type']}/{$structure['subtype']}");
            $info['related']['cid:' . $cid] = array(
                'filename' => $filename,
                'mimetype' => $structure['type'] . '/' . $structure['subtype'],
                'data' => $content
            );
            return $info;
        }

        $is_text = $structure['type'] == 'text' && $structure['subtype'] == 'plain';
        DebugEcho("handlePart: is_text: " . ($is_text ? 'true' : 'false'));
        $is_html = $structure['type'] == 'text' && $structure['subtype'] == 'html';
        DebugEcho("handlePart: is_html: " . ($is_html ? 'true' : 'false'));

        if (!isset($info['text'])) {
            $info['text'] = '';
        }
        if (!isset($info['html'])) {
            $info['html'] = '';
        }

        // If the part doesn't have a disposition and is not the default text or html, set the disposition to inline
        if (!$has_disposition && ((!$is_text || !empty($info['text'])) && (!$is_html || !empty($info['html'])))) {
            DebugEcho("handlePart: checking disposition");
            $is_web_image = $structure['type'] == 'image' && in_array($structure['subtype'], array('gif', 'png', 'jpeg', 'pjpeg'));
            $structure['disposition'] = $is_text || $is_html || $is_web_image ? 'inline' : 'attachment';
            $structure['disposition_fields'] = array();
            $has_disposition = TRUE;
        } else {
            DebugEcho("handlePart: not checking disposition");
        }

        // Attachments or inline content
        if ($has_disposition) {
            DebugEcho("handlePart: disposition set to " . $structure['disposition']);
            // This automatically handles primary content that has a content-disposition header on it
            if ($structure['disposition'] == 'inline' && $filename === '') {
                DebugEcho("handlePart: inline un-named");

                if ($is_text) {
                    if (empty($info['text'])) {
                        //DebugEcho("handlePart: text (first time)");
                        $info['text'] = $content;
                        return $info;
                    } else {
                        //DebugEcho("handlePart: text (nth time)");
                        $info['text'] .= "\r\n$content";
                        return $info;
                    }
                } else {
                    //DebugEcho("handlePart: not text");
                }

                if ($is_html && empty($info['html'])) {
                    //DebugEcho("handlePart: html");
                    $info['html'] = $content;
                    return $info;
                } else {
                    //DebugEcho("handlePart: not html");
                }
            }

            if ($structure['disposition'] == 'inline' && $structure['type'] == 'image') {
                //DebugEcho("handlePart: inline image");
                $info['text'] .= "<:inline $filename inline:>";
            }

            if (!isset($info[$structure['disposition']])) {
                //DebugEcho("handlePart: blank disposition");
                $info[$structure['disposition']] = array();
            }

            DebugEcho("handlePart: disposition: {$structure['disposition']} '$filename' {$structure['type']}/{$structure['subtype']}");
            $info[$structure['disposition']][] = array(
                'filename' => $filename,
                'mimetype' => $structure['type'] . '/' . $structure['subtype'],
                'data' => $content
            );
            return $info;
        } else {
            //DebugEcho("handlePart: no disposition set");
        }

        if ($is_text) {
            //DebugEcho("handlePart: text extracted");
            $info['text'] = $content;
            return $info;
        }

        if ($is_html) {
            //DebugEcho("handlePart: html extracted");
            $info['html'] = $content;
            return $info;
        }
    }

    /**
     * Tries to decrypt an S/MIME message using a private key
     *
     * @param array  &$info       The array of information about a message
     * @param array  $structure   The structure of this part
     * @param array  $smime_pair  An associative array containing an S/MIME certificate, private key and password
     * @return boolean  If the message was decrypted
     */
    static private function handleSMIMEDecryption(&$info, $structure, $smime_pair) {
        $plaintext_file = tempnam('', '__fMailbox_');
        $ciphertext_file = tempnam('', '__fMailbox_');

        $headers = array();
        $headers[] = "Content-Type: " . $structure['type'] . '/' . $structure['subtype'];
        $headers[] = "Content-Transfer-Encoding: " . $structure['encoding'];
        $header = "Content-Disposition: " . $structure['disposition'];
        foreach ($structure['disposition_fields'] as $field => $value) {
            $header .= '; ' . $field . '="' . $value . '"';
        }
        $headers[] = $header;

        file_put_contents($ciphertext_file, join("\r\n", $headers) . "\r\n\r\n" . $structure['data']);

        $private_key = openssl_pkey_get_private(
                $smime_pair['private_key']->read(), $smime_pair['password']
        );
        $certificate = $smime_pair['certificate']->read();

        $result = openssl_pkcs7_decrypt($ciphertext_file, $plaintext_file, $certificate, $private_key);
        unlink($ciphertext_file);

        if (!$result) {
            unlink($plaintext_file);
            return FALSE;
        }

        $contents = file_get_contents($plaintext_file);
        $info['raw_message'] = $contents;
        $info = self::handlePart($info, self::parseStructure($contents));
        $info['decrypted'] = TRUE;

        unlink($plaintext_file);
        return TRUE;
    }

    /**
     * Takes a message with an S/MIME signature and verifies it if possible
     *
     * @param array &$info       The array of information about a message
     * @param array $structure
     * @param array $smime_pair  An associative array containing an S/MIME certificate file
     * @return boolean  If the message was verified
     */
    static private function handleSMIMEVerification(&$info, $structure, $smime_pair) {
        $certificates_file = tempnam('', '__fMailbox_');
        $ciphertext_file = tempnam('', '__fMailbox_');

        file_put_contents($ciphertext_file, $info['raw_message']);

        $result = openssl_pkcs7_verify(
                $ciphertext_file, PKCS7_NOINTERN | PKCS7_NOVERIFY, $certificates_file, array(), $smime_pair['certificate']->getPath()
        );
        unlink($ciphertext_file);
        unlink($certificates_file);

        if (!$result || $result === -1) {
            return FALSE;
        }

        $info['verified'] = TRUE;

        return TRUE;
    }

    private static function remove_utf8_bom($text) {
        $bom = pack('H*', 'EFBBBF');
        $text = preg_replace("/^$bom/", '', $text);
        return $text;
    }

    /**
     * This works around a bug in MAMP 1.9.4+ and PHP 5.3 where iconv()
     * does not seem to properly assign the return value to a variable, but
     * does work when returning the value.
     *
     * @param string $in_charset   The incoming character encoding
     * @param string $out_charset  The outgoing character encoding
     * @param string $string       The string to convert
     * @return string  The converted string
     */
    static private function iconv($in_charset, $out_charset, $string) {
        $s = iconv($in_charset, "$out_charset//IGNORE", $string);
        if ($s === false) {
            EchoError("iconv: error converting from character set $in_charset to $out_charset");
            return $string;
        } else {
            return self::remove_utf8_bom($s);
        }
    }

    /**
     * Parses a string representation of an email into the persona, mailbox and host parts
     *
     * @param  string $string  The email string to parse
     * @return array  An associative array with the key `mailbox`, and possibly `host` and `personal`
     */
    static private function parseEmail($string) {
        $email_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+")(?:\.[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+"[ \t]*))*)@((?:[a-z0-9\\-]+\.)+[a-z]{2,}|\[(?:(?:[01]?\d?\d|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d?\d|2[0-4]\d|25[0-5])\])';
        $name_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\n\r]+"[ \t]*)(?:\.?[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*))*)';

        if (preg_match('~^[ \t]*' . $name_regex . '[ \t]*<[ \t]*' . $email_regex . '[ \t]*>[ \t]*$~ixD', $string, $match)) {
            $match[1] = trim($match[1]);
            if ($match[1][0] == '"' && substr($match[1], -1) == '"') {
                $match[1] = substr($match[1], 1, -1);
            }
            return array(
                'personal' => self::decodeHeader($match[1]),
                'mailbox' => self::decodeHeader($match[2]),
                'host' => self::decodeHeader($match[3])
            );
        } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*$~ixD', $string, $match)) {
            return array(
                'mailbox' => self::decodeHeader($match[1]),
                'host' => self::decodeHeader($match[2])
            );

// This handles the outdated practice of including the personal
// part of the email in a comment after the email address
        } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*\(([^)]+)\)[ \t]*$~ixD', $string, $match)) {
            $match[3] = trim($match[1]);
            if ($match[3][0] == '"' && substr($match[3], -1) == '"') {
                $match[3] = substr($match[3], 1, -1);
            }

            return array(
                'personal' => self::decodeHeader($match[3]),
                'mailbox' => self::decodeHeader($match[1]),
                'host' => self::decodeHeader($match[2])
            );
        }

        if (strpos($string, '@') !== FALSE) {
            list ($mailbox, $host) = explode('@', $string, 2);
            return array(
                'mailbox' => self::decodeHeader($mailbox),
                'host' => self::decodeHeader($host)
            );
        }

        return array(
            'mailbox' => self::decodeHeader($string),
            'host' => ''
        );
    }

    /**
     * Parses full email headers into an associative array
     *
     * @param  string $headers  The header to parse
     * @return array  The parsed headers
     */
    static public function parseHeaders($headers) {
        $headers = trim($headers);
        if (!strlen($headers)) {
            return array();
        }
        $header_lines = preg_split("#\r\n(?!\s)#", $headers);

        $single_email_fields = array('from', 'sender', 'reply-to', 'bcc');
        $multi_email_fields = array('to', 'cc');
        $additional_info_fields = array('content-type', 'content-disposition');

        $headers = array();
        foreach ($header_lines as $header_line) {
            //DebugEcho("headerline: $header_line");
            $header_line = preg_replace("#\r\n\s+#", ' ', $header_line);

            if (false !== strpos($header_line, ':')) {
                list ($header, $value) = preg_split('#:\s*#', $header_line, 2);
                $header = strtolower($header);

                $is_single_email = in_array($header, $single_email_fields);
                $is_multi_email = in_array($header, $multi_email_fields);
                $is_additional_info_field = in_array($header, $additional_info_fields);

                if ($is_additional_info_field) {
                    $pieces = preg_split('#;\s*#', $value, 2);
                    $value = $pieces[0];
                    //DebugEcho("parseHeaders: additional_info: $value");
                    //DebugDump($pieces);

                    $headers[$header] = array('value' => self::decodeHeader($value));

                    $fields = array();
                    if (!empty($pieces[1])) {
                        preg_match_all('#(\w+)\s*=\s*("([^"]+)"|([^\s;]+))(?=;|$)#', $pieces[1], $matches, PREG_SET_ORDER);
                        foreach ($matches as $match) {
                            $fields[$match[1]] = self::decodeHeader(!empty($match[4]) ? $match[4] : $match[3]);
                        }
                    }
                    $headers[$header]['fields'] = $fields;
                } elseif ($is_single_email) {
                    //DebugEcho("parseHeaders: $header:$value");
                    $headers[$header] = self::parseEmail($value);
                } elseif ($is_multi_email) {
                    $strings = array();

                    preg_match_all('#"[^"]+?"#', $value, $matches, PREG_SET_ORDER);
                    foreach ($matches as $i => $match) {
                        $strings[] = $match[0];
                        $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1);
                    }
                    preg_match_all('#\([^)]+?\)#', $value, $matches, PREG_SET_ORDER);
                    foreach ($matches as $i => $match) {
                        $strings[] = $match[0];
                        $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1);
                    }

                    $emails = explode(',', $value);
                    array_map('trim', $emails);
                    foreach ($strings as $i => $string) {
                        $emails = preg_replace(
                                '#:string' . ($i + 1) . '\b#', strtr($string, array('\\' => '\\\\', '$' => '\\$')), $emails, 1
                        );
                    }

                    $headers[$header] = array();
                    foreach ($emails as $email) {
                        $headers[$header][] = self::parseEmail($email);
                    }
                } elseif ($header == 'references') {
                    $headers[$header] = array_map(array('fMailbox', 'decodeHeader'), preg_split('#(?<=>)\s+(?=<)#', $value));
                } elseif ($header == 'received') {
                    if (!isset($headers[$header])) {
                        $headers[$header] = array();
                    }
                    $headers[$header][] = preg_replace('#\s+#', ' ', self::decodeHeader($value));
                } else {
                    $headers[$header] = self::decodeHeader($value);
                }
            }
        }
        return $headers;
    }

    /**
     * Parses a MIME message into an associative array of information
     *
     * The output includes the following keys:
     *
     *  - `'received'`: The date the message was received by the server
     *  - `'headers'`: An associative array of mail headers, the keys are the header names, in lowercase
     *
     * And one or more of the following:
     *
     *  - `'text'`: The plaintext body
     *  - `'html'`: The HTML body
     *  - `'attachment'`: An array of attachments, each containing:
     *   - `'filename'`: The name of the file
     *   - `'mimetype'`: The mimetype of the file
     *   - `'data'`: The raw contents of the file
     *  - `'inline'`: An array of inline files, each containing:
     *   - `'filename'`: The name of the file
     *   - `'mimetype'`: The mimetype of the file
     *   - `'data'`: The raw contents of the file
     *  - `'related'`: An associative array of related files, such as embedded images, with the key `'cid:{content-id}'` and an array value containing:
     *   - `'mimetype'`: The mimetype of the file
     *   - `'data'`: The raw contents of the file
     *  - `'verified'`: If the message contents were verified via an S/MIME certificate - if not verified the smime.p7s will be listed as an attachment
     *  - `'decrypted'`: If the message contents were decrypted via an S/MIME private key - if not decrypted the smime.p7m will be listed as an attachment
     *
     * All values in `headers`, `text` and `body` will have been decoded to
     * UTF-8. Files in the `attachment`, `inline` and `related` array will all
     * retain their original encodings.
     *
     * @param string  $message           The full source of the email message
     * @param boolean $convert_newlines  If `\r\n` should be converted to `\n` in the `text` and `html` parts the message
     * @return array  The parsed email message - see method description for details
     */
    static public function parseMessage($message, $convert_newlines = FALSE) {
        $info = array();
        list ($headers, $body) = explode("\r\n\r\n", $message, 2);
        $parsed_headers = self::parseHeaders($headers);
        $info['received'] = self::cleanDate(preg_replace('#^.*;\s*([^;]+)$#', '\1', $parsed_headers['received'][0]));
        $info['headers'] = array();
        foreach ($parsed_headers as $header => $value) {
            if (substr($header, 0, 8) == 'content-') {
                continue;
            }
            $info['headers'][$header] = $value;
        }

        $info['raw_headers'] = $headers;
        $info['raw_message'] = $message;

        $info['related'] = array();
        $info['attachment'] = array();
        $info['inline'] = array();
        $info = self::handlePart($info, self::parseStructure($body, $parsed_headers));

        unset($info['raw_message']);
        unset($info['raw_headers']);

        if ($convert_newlines) {
            //DebugEcho("parseMessage: converting newlines");
            if (isset($info['text'])) {
                $info['text'] = str_replace("\r\n", "\n", $info['text']);
            }
            if (isset($info['html'])) {
                $info['html'] = str_replace("\r\n", "\n", $info['html']);
            }
        }

        if (isset($info['text'])) {
            $info['text'] = preg_replace('#\r?\n$#D', '', $info['text']);
        } else {
            $info['text'] = '';
        }
        if (isset($info['html'])) {
            $info['html'] = preg_replace('#\r?\n$#D', '', $info['html']);
        } else {
            $info['html'] = '';
        }

        return $info;
    }

    /**
     * Takes a response from an IMAP command and parses it into a
     * multi-dimensional array
     *
     * @param string  $text       The IMAP command response
     * @param boolean $top_level  If we are parsing the top level
     * @return array  The parsed representation of the response text
     */
    static private function parseResponse($text, $top_level = FALSE) {
        $regex = '[\\\\\w.\[\]]+|"([^"\\\\]+|\\\\"|\\\\\\\\)*"|\((?:(?1)[ \t]*)*\)';

        if (preg_match('#\{(\d+)\}#', $text, $match)) {
            $regex = '\{' . $match[1] . '\}\r\n.{' . ($match[1]) . '}|' . $regex;
        }

        preg_match_all('#(' . $regex . ')#s', $text, $matches, PREG_SET_ORDER);
        $output = array();
        foreach ($matches as $match) {
            if (substr($match[0], 0, 1) == '"') {
                $output[] = str_replace('\\"', '"', substr($match[0], 1, -1));
            } elseif (substr($match[0], 0, 1) == '(') {
                $output[] = self::parseResponse(substr($match[0], 1, -1));
            } elseif (substr($match[0], 0, 1) == '{') {
                $output[] = preg_replace('#^[^\r]+\r\n#', '', $match[0]);
            } else {
                $output[] = $match[0];
            }
        }

        if ($top_level) {
            $new_output = array();
            $total_size = count($output);
            for ($i = 0; $i < $total_size; $i = $i + 2) {
                $new_output[strtolower($output[$i])] = $output[$i + 1];
            }
            $output = $new_output;
        }

        return $output;
    }

    /**
     * Takes the raw contents of a MIME message and creates an array that
     * describes the structure of the message
     *
     * @param string $data     The contents to get the structure of
     * @param string $headers  The parsed headers for the message - if not present they will be extracted from the `$data`
     * @return array  The multi-dimensional, associative array containing the message structure
     */
    static private function parseStructure($data, $headers = NULL) {
        if (!$headers) {
            list ($headers, $data) = preg_split("#^\r\n|\r\n\r\n#", $data, 2);
            $headers = self::parseHeaders($headers);
        }

        //DebugEcho('parseStructure: headers');
        //DebugDump($headers);

        if (!isset($headers['content-type'])) {
            $headers['content-type'] = array(
                'value' => 'text/plain',
                'fields' => array()
            );
        }

        list ($type, $subtype) = explode('/', strtolower($headers['content-type']['value']), 2);

        if ($type == 'multipart') {
            $structure = array(
                'type' => $type,
                'subtype' => $subtype,
                'parts' => array()
            );
            //Some email clients use boundary vs Boundary and PHP's array access is case sensitive so we have to check both
            $boundary = isset($headers['content-type']['fields']['boundary']) ? $headers['content-type']['fields']['boundary'] : $headers['content-type']['fields']['Boundary'];
            $start_pos = strpos($data, '--' . $boundary) + strlen($boundary) + 4;
            $end_pos = strrpos($data, '--' . $boundary . '--') - 2;
            $sub_contents = explode("\r\n--" . $boundary . "\r\n", substr($data, $start_pos, $end_pos - $start_pos));
            foreach ($sub_contents as $sub_content) {
                $structure['parts'][] = self::parseStructure($sub_content);
            }
            //DebugEcho('parseStructure: multipart');
            //DebugDump($structure);
        } else {
            $structure = array(
                'type' => $type,
                'type_fields' => !empty($headers['content-type']['fields']) ? $headers['content-type']['fields'] : array(),
                'subtype' => $subtype,
                'content_id' => isset($headers['content-id']) ? $headers['content-id'] : NULL,
                'encoding' => isset($headers['content-transfer-encoding']) ? strtolower($headers['content-transfer-encoding']) : '8bit',
                'disposition' => isset($headers['content-disposition']) ? strtolower($headers['content-disposition']['value']) : NULL,
                'disposition_fields' => isset($headers['content-disposition']) ? $headers['content-disposition']['fields'] : array());

            //DebugEcho('parseStructure: content-type');
            //DebugDump($structure);

            $structure['data'] = $data;
        }

        return $structure;
    }

    /**
     * Resets the configuration of the class
     *
     * @internal
     *
     * @return void
     */
    static public function reset() {
        self::$smime_pairs = array();
    }

    /**
     * Takes an associative array and unfolds the keys and values so that the
     * result in an integer-indexed array of `0 => key1, 1 => value1, 2 => key2,
     * 3 => value2, ...`.
     *
     * @param array $array  The array to unfold
     * @return array  The unfolded array
     */
    static private function unfoldAssociativeArray($array) {
        $new_array = array();
        foreach ($array as $key => $value) {
            $new_array[] = $key;
            $new_array[] = $value;
        }
        return $new_array;
    }

    /**
     * A counter to use for generating command keys
     *
     * @var integer
     */

    /**
     * The connection resource
     *
     * @var resource
     */
    private $connection;

    /**
     * If debugging has been enabled
     *
     * @var boolean
     */
    private $debug;

    /**
     * The server hostname or IP address
     *
     * @var string
     */
    private $host;

    /**
     * The password for the account
     *
     * @var string
     */
    private $password;

    /**
     * The port for the server
     *
     * @var integer
     */
    private $port;

    /**
     * If the connection to the server should be secure
     *
     * @var boolean
     */
    private $secure;

    /**
     * The timeout for the connection
     *
     * @var integer
     */
    private $timeout = 5;

    /**
     * The type of mailbox, `'imap'` or `'pop3'`
     *
     * @var string
     */
    private $type;

    /**
     * The username for the account
     *
     * @var string
     */
    private $username;
    private $server;

    /**
     * Configures the connection to the server
     *
     * Please note that the GMail POP3 server does not act like other POP3
     * servers and the GMail IMAP server should be used instead. GMail POP3 only
     * allows retrieving a message once - during future connections the email
     * in question will no longer be available.
     *
     * @param  string  $type      The type of mailbox, `'pop3'` or `'imap'`
     * @connection pConnection
     * @return fMailbox
     */
    public function __construct($type, $connection, $server) {

        $valid_types = array('imap', 'pop3');
        if (!in_array($type, $valid_types)) {
            throw new fProgrammerException('The mailbox type specified, %1$s, in invalid. Must be one of: %2$s.', $type, join(', ', $valid_types));
        }

        $this->type = $type;
        $this->connection = $connection;
        $this->server = $server;
    }

    /**
     * Disconnects from the server
     *
     * @return void
     */
    public function __destruct() {
        $this->close();
    }

    /**
     * Closes the connection to the server
     *
     * @return void
     */
    public function close() {
        if (!$this->connection) {
            return;
        }
        $this->server->close();

        $this->connection->close();
        $this->connection = NULL;
    }

    /**
     * Connects to the server
     *
     * @return void
     */
    private function connect() {
        $this->connection->open();
    }

    /**
     * Deletes one or more messages from the server
     *
     * Passing more than one UID at a time is more efficient for IMAP mailboxes,
     * whereas POP3 mailboxes will see no difference in performance.
     *
     * @param  integer|array $uid  The UID(s) of the message(s) to delete
     * @return void
     */
    public function deleteMessages($uid) {
        $this->connect();
        $this->server->deleteMessages($uid);
    }

    /**
     * Sets if debug messages should be shown
     *
     * @param  boolean $flag  If debugging messages should be shown
     * @return void
     */
    public function enableDebugging($flag) {
        $this->debug = (boolean) $flag;
    }

    /**
     * Retrieves a single message from the server
     *
     * The output includes the following keys:
     *
     *  - `'uid'`: The UID of the message
     *  - `'received'`: The date the message was received by the server
     *  - `'headers'`: An associative array of mail headers, the keys are the header names, in lowercase
     *
     * And one or more of the following:
     *
     *  - `'text'`: The plaintext body
     *  - `'html'`: The HTML body
     *  - `'attachment'`: An array of attachments, each containing:
     *   - `'filename'`: The name of the file
     *   - `'mimetype'`: The mimetype of the file
     *   - `'data'`: The raw contents of the file
     *  - `'inline'`: An array of inline files, each containing:
     *   - `'filename'`: The name of the file
     *   - `'mimetype'`: The mimetype of the file
     *   - `'data'`: The raw contents of the file
     *  - `'related'`: An associative array of related files, such as embedded images, with the key `'cid:{content-id}'` and an array value containing:
     *   - `'mimetype'`: The mimetype of the file
     *   - `'data'`: The raw contents of the file
     *  - `'verified'`: If the message contents were verified via an S/MIME certificate - if not verified the smime.p7s will be listed as an attachment
     *  - `'decrypted'`: If the message contents were decrypted via an S/MIME private key - if not decrypted the smime.p7m will be listed as an attachment
     *
     * All values in `headers`, `text` and `body` will have been decoded to
     * UTF-8. Files in the `attachment`, `inline` and `related` array will all
     * retain their original encodings.
     *
     * @param  integer $uid               The UID of the message to retrieve
     * @param  boolean $convert_newlines  If `\r\n` should be converted to `\n` in the `text` and `html` parts the message
     * @return array  The parsed email message - see method description for details
     */
    public function fetchMessage($uid, $convert_newlines = FALSE) {
        global $g_postie;

        $this->connect();

        $source = $this->fetchMessageSource($uid);
        do_action('postie_raw', $source);

        $info = self::parseMessage($source, $convert_newlines);
        $info['uid'] = $uid;

        if ($g_postie->is_debugmode()) {
            $g_postie->save_email_debug($source, $info);
        }

        return $info;
    }

    /**
     * Retrieves the raw source of a single message from the server
     *
     * This method is primarily useful for storing the raw source of a message.
     * Normal use of fMailbox would involved calling ::fetchMessage(), which
     * calls this method and then ::parseMessage().
     *
     * @param  integer $uid  The UID of the message to retrieve
     * @return string  The raw message source of the email
     */
    public function fetchMessageSource($uid) {
        return $this->server->fetchMessageSource($uid);
    }

    /**
     * Count the number of unread messages
     * 
     * @return integer The number of unread messages
     */
    public function countMessages() {
        return $this->server->countMessages();
    }

    /**
     * Gets a list of messages from the server
     *
     * The structure of the returned array is:
     *
     * {{{
     * array(
     *     (integer) {uid} => array(
     *         'uid'         => (integer) {a unique identifier for this message on this server},
     *         'received'    => (string) {date message was received},
     *         'size'        => (integer) {size of message in bytes},
     *         'date'        => (string) {date message was sent},
     *         'from'        => (string) {the from header value},
     *         'subject'     => (string) {the message subject},
     *         'message_id'  => (string) {optional - the message-id header value, should be globally unique},
     *         'to'          => (string) {optional - the to header value},
     *         'in_reply_to' => (string) {optional - the in-reply-to header value}
     *     ), ...
     * )
     * }}}
     *
     * All values will have been decoded to UTF-8.
     *
     * @param  integer $limit  The number of messages to retrieve
     * @param  integer $page   The page of messages to retrieve
     * @return array  A list of messages on the server - see method description for details
     */
    public function listMessages($limit = NULL, $page = NULL) {
        return $this->server->listMessages($limit, $page);
    }

    /**
     * Reads responses from the server
     *
     * @param  integer|string $expect  The expected number of lines of response or a regex of the last line
     * @return array  The lines of response from the server
     */
    private function read($expect = NULL) {
        if (!$this->connection) {
            throw new fProgrammerException('Unable to send data since the connection has already been closed');
        }
        return $this->connection->read($expect);
    }

    /**
     * Sends commands to the IMAP or POP3 server
     *
     * @param  string  $command   The command to send
     * @param  integer $expected  The number of lines or regex expected for a POP3 command
     * @return array  The response from the server
     */
    private function write($command, $expected = NULL) {
        if (!$this->connection) {
            throw new fProgrammerException('Unable to send data since the connection has already been closed');
        }

        return $this->connection->write($command, $expected);
    }

}

/**
 * Copyright (c) 2010-2012 Will Bond <will@flourishlib.com>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */