<?php
/**
 * Decoder.
 * @package magic.core
 * @subpackage tool.mail
 */
/**
 * 受信メールをデコードします.
 * <p>
 * 一般的には受信メールをパイプして、STDINを使用して、
 * このクラスに渡します。
 * </p>
 * @package magic.core
 * @subpackage tool.mail
 * @author T.Okumura
 * @version 1.0.0
 * @final
 */
final class Decoder {
    /**
     * PHPの内部文字コードを保持します.
     * @var string
     */
    private $_internalEncoding = 'utf-8';
    /**
     * ヘッダを保持します.
     * @var array
     */
    private $_headers = array();
    /**
     * ボディを保持します.
     * @var array
     */
    private $_body = array();
    /**
     * HTMLメールのHTMLを保持します.
     * @var array
     */
    private $_html = array();
    /**
     * 添付ファイルを保持します.
     * @var array
     */
    private $_attachments = array();
    /**
     * メールを保持します.
     * @var array
     */
    private $_mails = array();
    /**
     * コンストラクタ.
     */
    public function __construct() {
    }
    /**
     * 内部文字コードを設定します.
     * @param string $internalEncoding 内部文字コード
     */
    public function setInternalEncoding($internalEncoding) {
        $this->_internalEncoding = strtolower($internalEncoding);
    }
    /**
     * デコードを実行します.
     *
     * @param string $input メール
     */
    public function decode($input) {
        list($header, $body) = $this->_split($input);
        $this->_decode($header, $body);
    }
    /**
     * ヘッダを取得します.
     * @param string $name ヘッダのパーツ名
     * @return string ヘッダの値
     */
    public function getHeader($name) {
        return $this->_headers[strtolower($name)];
    }
    /**
     * FROMアドレスを取得します.
     * @return string FROMアドレス
     */
    public function getFrom() {
        return $this->getHeader("from");
    }
    /**
     * メールのSubjectを取得します.
     * @return string メールのSubject
     */
    public function getSubject() {
        return $this->getHeader("subject");
    }
    /**
     * ボディを取得します.
     * @return string ボディ
     */
    public function getBody() {
        return $this->_body['content'];
    }
    /**
     * HTMLメールのHTMLを取得します.
     * @return string HTML
     */
    public function getHtml() {
        return $this->_html['content'];
    }
    /**
     * 添付ファイルを取得します.
     * @return array 添付ファイルの配列
     */
    public function getAttachments() {
        return $this->_attachments;
    }
    /**
     * インラインイメージを取得します.
     * @return array インラインイメージ
     */
    public function getInlines() {
        return $this->_html['inline'];
    }
    /**
     * メールをヘッダとボディに分けます.
     * @param string $input メール
     * @return array ヘッダとボディの配列
     */
    private function _split($input) {
        if (preg_match('/^(.*?)\r?\n\r?\n(.*)/s', $input, $matches)) {
            return array($matches[1], $matches[2]);
        }
        return array($input, '');
    }
    /**
     * デコード処理を実行します.
     * @param string $header ヘッダ
     * @param string $body ボディ
     */
    private function _decode($header, $body) {
        $this->_headers = $this->_createHeaders($header);
        $content = $this->_createContentInfo($this->_headers);
        switch (strtolower($content['type'])) {
            case 'multipart/mixed':
                $mixed = $this->_decodeMixedPart($content, $body);
                $this->_body = $mixed['body'];
                $this->_html = $mixed['html'];
                $this->_attachments = $mixed['att'];
                $this->_mails = $mixed['mails'];
                break;
            case 'multipart/alternative':
                $alter = $this->_decodeAlternativePart($content, $body);
                $this->_body = $alter['body'];
                $this->_html = $alter['html'];
                break;
            case 'multipart/related':
                $related = $this->_decodeRelatedPart($content, $body);
                $this->_body = $related['body'];
                $this->_html = $related['html'];
                break;
            case 'multipart/digest':
                $this->_mails = $this->_decodeDigestPart($content, $body);
                break;
            default:
                $part = array();
                $part['body'] = $body;
                $part['content'] = $content;
                $part['type'] = $content['type'];
                if ($part['type'] === 'text/html') {
                    $this->_html = $this->_createMimeObject($part);
                } else {
                    $this->_body = $this->_createMimeObject($part);
                }
                break;
        }
    }
    /**
     * multipart/mixedをデコードします.
     * @param array $content 解析されたコンテント
     * @param string $body ボディ
     * @return array デコード結果の配列
     */
    private function _decodeMixedPart(array $content, $body) {
        $parts = $this->_splitByBoundary($body, $content['boundary']);
        $mixed = array('body' => NULL, 'html' => NULL, 'attachments' => array(), 'mails' => array());
        $notBodyPart = FALSE;
        foreach ($parts as $partOfMessage) {
            $part = $this->_createPart($partOfMessage);
            switch ($part['type']) {
                case 'multipart/alternative':
                    $alter = $this->_decodeAlternativePart($part['content'], $part['body']);
                    $mixed['body'] = $alter['body'];
                    $mixed['html'] = $alter['html'];
                    break;
                case 'multipart/related':
                    $related = $this->_decodeRelatedPart($part['content'], $part['body']);
                    if (!is_null($related['body'])) {
                        $mixed['body'] = $related['body'];
                    }
                    $mixed['html'] = $related['html'];
                    break;
                case 'multipart/digest':
                    $mixed['mails'] = $this->_decodeDigestPart($part['content'], $part['body']);
                    break;
                case 'text/html':
                    if (!$notBodyPart) {
                        $mixed['html'] = $this->_createMimeObject($part);
                        break;
                    }
                case 'text/plain':
                    if (!$notBodyPart) {
                        $mixed['body'] = $this->_createMimeObject($part);
                        break;
                    }
                default:
                    $file = array();
                    $file['name'] = $part['content']['name'];
                    $file['data'] = $this
                            ->_decodeString($part['body'], $part['content']['encoding'], $part['content']['charset'],
                                    FALSE);
                    $file['type'] = $part['type'];
                    list(, $file['ext']) = split('/', $part['type']);
                    $file['charset'] = $part['content']['charset'];
                    $file['encoding'] = $part['content']['encoding'];
                    $file['disposition'] = $part['content']['disposition'];
                    $file['headers'] = $part['headers'];
                    $mixed['att'][] = $file;
            }
            $notBodyPart = TRUE;
        }
        return $mixed;
    }
    /**
     * multipart/alternativeをデコードします.
     * @param array $content 解析されたコンテント
     * @param string $body バウンダリで分割されたボディ
     * @return array デコード結果の配列
     */
    private function _decodeAlternativePart(array $content, $body) {
        $parts = $this->_splitByBoundary($body, $content['boundary']);
        $alter = array('body' => NULL, 'html' => NULL);
        foreach ($parts as $partOfMessage) {
            $part = $this->_createPart($partOfMessage);
            switch ($part['type']) {
                case 'text/plain':
                    $alter['body'] = $this->_createMimeObject($part);
                    break;
                case 'text/html':
                    $alter['html'] = $this->_createMimeObject($part);
                    break;
                case 'multipart/related':
                    $related = $this->_decodeRelatedPart($part['content'], $part['body']);
                    if (!is_null($related['body'])) {
                        $alter['body'] = $related['body'];
                    }
                    $alter['html'] = $related['html'];
                    break;
            }
        }
        return $alter;
    }
    /**
     * multipart/relatedをデコードします.
     * @param array $content 解析されたコンテント
     * @param string $body バウンダリで分割されたボディ
     * @return array デコード結果の配列
     */
    private function _decodeRelatedPart(array $content, $body) {
        $parts = $this->_splitByBoundary($body, $content['boundary']);
        $related = array('body' => NULL, 'html' => NULL);
        foreach ($parts as $partOfMessage) {
            $part = $this->_createPart($partOfMessage);
            if ($part['type'] === 'text/html') {
                $related['html'] = $this->_createMimeObject($part);
            } elseif ($part['type'] === 'multipart/alternative') {
                $alter = $this->_decodeAlternativePart($part['content'], $part['body']);
                $related['body'] = $alter['body'];
                $related['html'] = $alter['html'];
            } else {
                $body = $this
                        ->_decodeString($part['body'], $part['content']['encoding'], $part['content']['charset'], FALSE);
                $cid = isset($part['headers']['content-id']) ? $part['headers']['content-id'] : '';
                $related['html']['inline'][] = array('cid' => $cid, 'data' => $body, 'type' => $part['type'],
                        'encoding' => $part['content']['encoding']);
            }
        }
        return $related;
    }
    /**
     * multipart/digestをデコードします.
     * @param array $content 解析されたコンテント
     * @param string $body バウンダリで分割されたボディ
     * @return array デコード結果の配列
     */
    private function _decodeDigestPart(array $content, $body) {
        $parts = $this->_splitByBoundary($body, $content['boundary']);
        $mails = array();
        foreach ($parts as $partOfMessage) {
            $part = $this->_createPart($partOfMessage);
            list($header, $body) = $this->_split($part['body']);
            $mails[] = $this->_decode($header, $body);
        }
        return $mails;
    }
    /**
     * ヘッダを構築します.
     * @param string $header ヘッダ
     * @return array 構築後のヘッダの配列
     */
    private function _createHeaders($header) {
        $result = array();
        if (empty($header)) {
            return $result;
        }
        if (preg_match('/(\r\n|\r|\n)/', $header, $matches)) {
            $headers = explode($matches[0], preg_replace("/{$matches[0]}(\t|\s)+/", ' ', $header));
        } else {
            $headers = array($header);
        }
        foreach ($headers as $line) {
            if (empty($line)) {
                break;
            }
            list($name, $value) = explode(":", $line, 2);
            $name = strtolower($name);
            $value = ltrim($value);
            $count = preg_match_all('/=\?([^?]+)\?(q|b)\?([^?]*)\?=/i', $value, $matches);
            if ($count > 0) {
                $value = str_replace('?= =?', '?==?', $value);
                for ($i = 0; $i < $count; $i++) {
                    $encoding = (strtolower($matches[2][$i]) === 'b') ? 'base64' : 'quoted-printable';
                    $encoded = $this->_decodeString($matches[3][$i], $encoding, $matches[1][$i]);
                    $value = str_replace($matches[0][$i], $encoded, $value);
                }
            }
            if (isset($result[$name])) {
                if (is_array($result[$name])) {
                    $result[$name][] = $value;
                } else {
                    $result[$name] = array($result[$name], $value);
                }
            } else {
                $result[$name] = $value;
            }
        }
        return array_change_key_case($result);
    }
    /**
     * Contentで始まる部分を解析します.
     * @param string $headers ヘッダ
     * @return array Contentの解析結果の配列
     */
    private function _createContentInfo($headers) {
        $content = array();
        foreach ($headers as $key => $value) {
            switch ($key) {
                case 'content-type':
                    $values = $this->_parseHeaderValue($value);
                    $content['type'] = $values['value'];
                    if (isset($values['boundary'])) {
                        $content['boundary'] = $values['boundary'];
                    }
                    if (isset($values['charset'])) {
                        $content['charset'] = $values['charset'];
                    }
                    break;
                case 'content-disposition':
                    $values = $this->_parseHeaderValue($value);
                    $content['disposition'] = $values['value'];
                    $filename = NULL;
                    if (isset($values['filename'])) {
                        $filename = $values['filename'];
                    } elseif (isset($values['filename*']) || isset($values['filename*0*'])
                            || isset($values['filename*0'])) {
                        $buffer = array();
                        foreach ($values as $k => $v) {
                            if (strpos($k, 'filename*') !== FALSE) {
                                $buffer[] = $v;
                            }
                        }
                        $filename = implode('', $buffer);
                    }
                    if (!is_null($filename)) {
                        if (preg_match("/^([a-zA-Z0-9\-]+)'([a-z]{2-5})?'(%.+)$/", $filename, $matches) === 1) {
                            $content['name'] = $this->_decodeString(urldecode($matches[3]), '', $matches[1]);
                        } elseif (preg_match('/=\?([^?]+)\?(q|b)\?([^?]*)\?=/i', $filename, $matches) === 1) {
                            $encoding = (strtolower($matches[2]) === 'b') ? 'base64' : 'quoted-printable';
                            $content['name'] = $this->_decodeString($matches[3], $encoding, $matches[1]);
                        } else {
                            $content['name'] = $filename;
                        }
                    }
                    break;
                case 'content-transfer-encoding':
                    $values = $this->_parseHeaderValue($value);
                    $content['encoding'] = $values['value'];
                    break;
            }
        }
        return $content;
    }
    /**
     * マイムオブジェクトを構築します.
     * @param array $part マイムのパーツ
     * @return array マイムオブジェクト
     */
    private function _createMimeObject(array $part) {
        $mime = array();
        $mime['content'] = $this
                ->_decodeString($part['body'], $part['content']['encoding'], $part['content']['charset'], FALSE);
        $mime['headers'] = $part['headers'];
        $mime['charset'] = $part['content']['charset'];
        $mime['encoding'] = $part['content']['encoding'];
        $mime['disposition'] = $part['content']['disposition'];
        return $mime;
    }
    /**
     * ヘッダの値をパースします.
     * @param string $string パースする値
     * @return array パースされた値の配列
     */
    private function _parseHeaderValue($string) {
        $result = array('params' => array());
        if (($pos = strpos($string, ";")) === FALSE) {
            $result['value'] = $string;
            return $result;
        }
        $string = preg_replace_callback('/".+[^\\\\]"|\'.+[^\\\\]\'/U', array($this, '_parseHeaderValueCallback'),
                $string);
        $result['value'] = substr($string, 0, $pos);
        $string = ltrim(substr($string, $pos + 1));
        if (empty($string) || $string === ';') {
            return $result;
        }
        foreach (array_map('trim', explode(';', $string)) as $param) {
            if (empty($param)) {
                continue;
            }
            list($name, $value) = explode('=', $param, 2);
            $name = strtolower($name);
            if (is_null($value)) {
                $result['params'][] = $name;
            } else {
                $quote = $value{0};
                if ($quote === '"' || $quote === "'") {
                    if ($quote === substr($value, -1, 1)) {
                        $value = str_replace("\\{$quote}", $quote, substr($value, 1, -1));
                    }
                }
                $result[$name] = str_replace('__%SC%__', ';', $value);
            }
        }
        return $result;
    }
    /**
     * 文字列をデコードします.
     * @param string $string デコードする文字列
     * @param string $encoding デコードするエンコーディングタイプ
     * @param string $charset 返却する文字コード
     * @param bool $isHeader ヘッダかどうかのフラグ
     * @return string デコードされた文字列
     */
    private function _decodeString($string, $encoding, $charset, $isHeader = true) {
        switch (strtolower($encoding)) {
            case 'base64':
                $string = base64_decode($string);
                break;
            case 'quoted-printable':
                $string = quoted_printable_decode(($isHeader ? str_replace("_", " ", $string) : $string));
                break;
        }
        if ($charset && $this->_internalEncoding != strtolower($charset)) {
            $string = mb_convert_encoding($string, $this->_internalEncoding, $charset);
        }
        return $string;
    }
    /**
     * ヘッダの値をパースするためのコールバックファンクション.
     * @param array $matches 値の配列
     * @return string 置換後の文字列
     */
    private function _parseHeaderValueCallback(array $matches) {
        return str_replace(';', '__%SC%__', $matches[0]);
    }
    /**
     * バウンダリで分割します.
     * @param string $body ボディ
     * @param string $boundary バウンダリ
     * @return array 分割後の文字列の配列
     */
    private function _splitByBoundary($body, $boundary) {
        $parts = array_map('ltrim', explode('--' . $boundary, $body));
        array_shift($parts);
        array_pop($parts);
        return $parts;
    }
    /**
     * パーツを作成します.
     * @param string $partOfMessage バウンダリで分割された一つ
     * @return array パーツ
     */
    private function _createPart($partOfMessage) {
        $part = array();
        list($part['header'], $part['body']) = $this->_split($partOfMessage);
        $part['headers'] = $this->_createHeaders($part['header']);
        $part['content'] = $this->_createContentInfo($part['headers']);
        $part['type'] = $part['content']['type'];
        return $part;
    }
}
// EOF.