<?php
/**
 * @class mimeMessage
 *
 * @package Clearbricks
 * @subpackage Mail
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright GPL-2.0-only
 */

class mimeMessage
{
    protected $from_email       = null;
    protected $from_name        = null;
    protected $ctype_primary    = null;
    protected $ctype_secondary  = null;
    protected $ctype_parameters = [];
    protected $d_parameters     = [];
    protected $disposition      = null;
    protected $headers          = [];
    protected $body             = null;
    protected $parts            = [];

    public function __construct($message)
    {
        list($header, $body) = $this->splitBodyHeader($message);
        $this->decode($header, $body);
    }

    public function getMainBody()
    {
        # Look for the main body part (text/plain or text/html)
        if ($this->body && $this->ctype_primary == 'text' && $this->ctype_secondary == 'plain') {
            return $this->body;
        }

        foreach ($this->parts as $part) {
            if (($body = $part->getBody()) !== null) {
                return $body;
            }
        }

        return;
    }

    public function getBody()
    {
        return $this->body;
    }

    public function getHeaders()
    {
        return $this->headers;
    }

    public function getHeader($hdr, $only_last = false)
    {
        $hdr = strtolower($hdr);

        if (!isset($this->headers[$hdr])) {
            return;
        }

        if ($only_last && is_array($this->headers[$hdr])) {
            $r = $this->headers[$hdr];
            return array_pop($r);
        }

        return $this->headers[$hdr];
    }

    public function getFrom()
    {
        return [$this->from_email, $this->from_name];
    }

    public function getAllFiles()
    {
        $parts = [];
        foreach ($this->parts as $part) {
            $body     = $part->getBody();
            $filename = $part->getFileName();
            if ($body && $filename) {
                $parts[] = [
                    'filename' => $filename,
                    'content'  => $part->getBody()
                ];
            } else {
                $parts = array_merge($parts, $part->getAllFiles());
            }
        }

        return $parts;
    }

    public function getFileName()
    {
        if (isset($this->ctype_parameters['name'])) {
            return $this->ctype_parameters['name'];
        }

        if (isset($this->d_parameters['filename'])) {
            return $this->d_parameters['filename'];
        }

        return;
    }

    protected function decode($headers, $body, $default_ctype = 'text/plain')
    {
        $headers = $this->parseHeaders($headers);

        foreach ($headers as $v) {
            if (isset($this->headers[strtolower($v['name'])]) &&
                !is_array($this->headers[strtolower($v['name'])])) {
                $this->headers[strtolower($v['name'])]   = [$this->headers[strtolower($v['name'])]];
                $this->headers[strtolower($v['name'])][] = $v['value'];
            } elseif (isset($this->headers[strtolower($v['name'])])) {
                $this->headers[strtolower($v['name'])][] = $v['value'];
            } else {
                $this->headers[strtolower($v['name'])] = $v['value'];
            }
        }

        foreach ($headers as $k => $v) {
            $headers[$k]['name'] = strtolower($headers[$k]['name']);
            switch ($headers[$k]['name']) {
                case 'from':
                    list($this->from_name, $this->from_email) = $this->decodeSender($headers[$k]['value']);
                    break;

                case 'content-type':
                    $content_type = $this->parseHeaderValue($headers[$k]['value']);

                    if (preg_match('/([0-9a-z+.-]+)\/([0-9a-z+.-]+)/i', $content_type['value'], $regs)) {
                        $this->ctype_primary   = strtolower($regs[1]);
                        $this->ctype_secondary = strtolower($regs[2]);
                    }

                    if (isset($content_type['other'])) {
                        while (list($p_name, $p_value) = each($content_type['other'])) {
                            $this->ctype_parameters[$p_name] = $p_value;
                        }
                    }
                    break;

                case 'content-disposition':
                    $content_disposition = $this->parseHeaderValue($headers[$k]['value']);
                    $this->disposition   = $content_disposition['value'];
                    if (isset($content_disposition['other'])) {
                        while (list($p_name, $p_value) = each($content_disposition['other'])) {
                            $this->d_parameters[$p_name] = $p_value;
                        }
                    }
                    break;

                case 'content-transfer-encoding':
                    $content_transfer_encoding = $this->parseHeaderValue($headers[$k]['value']);
                    break;
            }
        }

        if (isset($content_type)) {
            switch (strtolower($content_type['value'])) {
                case 'text/plain':
                case 'text/html':
                    $encoding   = isset($content_transfer_encoding) ? $content_transfer_encoding['value'] : '7bit';
                    $charset    = isset($this->ctype_parameters['charset']) ? $this->ctype_parameters['charset'] : null;
                    $this->body = $this->decodeBody($body, $encoding);
                    $this->body = text::cleanUTF8(@text::toUTF8($this->body, $charset));
                    break;

                case 'multipart/parallel':
                case 'multipart/appledouble': // Appledouble mail
                case 'multipart/report':      // RFC1892
                case 'multipart/signed':      // PGP
                case 'multipart/digest':
                case 'multipart/alternative':
                case 'multipart/related':
                case 'multipart/mixed':
                    if (!isset($content_type['other']['boundary'])) {
                        throw new Exception('No boundary found');
                    }

                    $default_ctype = (strtolower($content_type['value']) === 'multipart/digest') ? 'message/rfc822' : 'text/plain';

                    $parts = $this->boundarySplit($body, $content_type['other']['boundary']);
                    for ($i = 0; $i < count($parts); $i++) {
                        $this->parts[] = new self($parts[$i]);
                    }
                    break;

                case 'message/rfc822':
                    $this->parts[] = new self($body);
                    break;

                default:
                    if (!isset($content_transfer_encoding['value'])) {
                        $content_transfer_encoding['value'] = '7bit';
                    }
                    $this->body = $this->decodeBody($body, $content_transfer_encoding['value']);
                    break;
            }
        } else {
            $ctype                 = explode('/', $default_ctype);
            $this->ctype_primary   = $ctype[0];
            $this->ctype_secondary = $ctype[1];
            $this->body            = $this->decodeBody($body, '7bit');
            $this->body            = text::cleanUTF8(@text::toUTF8($this->body));
        }
    }

    protected function splitBodyHeader($input)
    {
        if (preg_match('/^(.*?)\r?\n\r?\n(.*)/s', $input, $match)) {
            return [$match[1], $match[2]];
        } else {
            # No body found
            return [$input, ''];
        }
    }

    protected function parseHeaders($input)
    {
        if (!$input) {
            return [];
        }

        # Unfold the input
        $input   = preg_replace("/\r?\n/", "\r\n", $input);
        $input   = preg_replace("/\r\n(\t| )+/", ' ', $input);
        $headers = explode("\r\n", trim($input));

        $res = [];

        # Remove first From line if exists
        if (strpos($headers[0], 'From ') === 0) {
            array_shift($headers);
        }

        foreach ($headers as $value) {
            $hdr_name = substr($value, 0, $pos = strpos($value, ':'));

            $hdr_value = substr($value, $pos + 1);

            if ($hdr_value[0] == ' ') {
                $hdr_value = substr($hdr_value, 1);
            }

            $res[] = [
                'name'  => $hdr_name,
                'value' => $this->decodeHeader($hdr_value)
            ];
        }

        return $res;
    }

    protected function parseHeaderValue($input)
    {

        if (($pos = strpos($input, ';')) !== false) {
            $return['value'] = trim(substr($input, 0, $pos));
            $input           = trim(substr($input, $pos + 1));

            if (strlen($input) > 0) {
                # This splits on a semi-colon, if there's no preceeding backslash
                # Now works with quoted values; had to glue the \; breaks in PHP
                # the regex is already bordering on incomprehensible
                $splitRegex = '/([^;\'"]*[\'"]([^\'"]*([^\'"]*)*)[\'"][^;\'"]*|([^;]+))(;|$)/';
                preg_match_all($splitRegex, $input, $matches);
                $parameters = [];
                for ($i = 0; $i < count($matches[0]); $i++) {
                    $param = $matches[0][$i];
                    while (substr($param, -2) == '\;') {
                        $param .= $matches[0][++$i];
                    }
                    $parameters[] = $param;
                }

                for ($i = 0; $i < count($parameters); $i++) {
                    $param_name  = trim(substr($parameters[$i], 0, $pos = strpos($parameters[$i], '=')), "'\";\t\\ ");
                    $param_value = trim(str_replace('\;', ';', substr($parameters[$i], $pos + 1)), "'\";\t\\ ");
                    if ($param_value[0] == '"') {
                        $param_value = substr($param_value, 1, -1);
                    }

                    if (preg_match('/\*$/', $param_name)) {
                        $return['other'][strtolower(substr($param_name, 0, -1))] = $this->parserHeaderSpecialValue($param_value);
                    } else {
                        $return['other'][strtolower($param_name)] = $param_value;
                    }
                }
            }
        } else {
            $return['value'] = trim($input);
        }

        return $return;
    }

    protected function parserHeaderSpecialValue($value)
    {
        if (strpos($value, "''") === false) {
            return $value;
        }

        list($charset, $value) = explode("''", $value);
        return @text::toUTF8(rawurldecode($value), $charset);
    }

    protected function boundarySplit($input, $boundary)
    {
        $parts = [];

        $bs_possible = substr($boundary, 2, -2);
        $bs_check    = '\"' . $bs_possible . '\"';

        if ($boundary == $bs_check) {
            $boundary = $bs_possible;
        }

        $tmp = explode('--' . $boundary, $input);

        for ($i = 1; $i < count($tmp) - 1; $i++) {
            $parts[] = $tmp[$i];
        }

        return $parts;
    }

    protected function decodeHeader($input)
    {
        # Remove white space between encoded-words
        $input = preg_replace('/(=\?[^?]+\?(q|b)\?[^?]*\?=)(\s)+=\?/i', '\1=?', $input);

        # Non encoded
        if (!preg_match('/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)/i', $input)) {
            return @text::toUTF8($input);
        }

        # For each encoded-word...
        while (preg_match('/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)/i', $input, $matches)) {
            $encoded  = $matches[1];
            $charset  = $matches[2];
            $encoding = $matches[3];
            $text     = $matches[4];

            switch (strtolower($encoding)) {
                case 'b':
                    $text = base64_decode($text);
                    break;

                case 'q':
                    $text = str_replace('_', ' ', $text);
                    preg_match_all('/=([a-f0-9]{2})/i', $text, $matches);
                    foreach ($matches[1] as $value) {
                        $text = str_replace('=' . $value, chr(hexdec($value)), $text);
                    }
                    break;
            }
            $text  = @text::toUTF8($text, $charset);
            $input = str_replace($encoded, $text, $input);
        }

        return text::cleanUTF8($input);
    }

    protected function decodeSender($sender)
    {
        if (preg_match('/([\'|"])?(.*)(?(1)[\'|"])\s+<([\w\-=!#$%^*\'+\\.={}|?~]+@[\w\-=!#$%^*\'+\\.={}|?~]+[\w\-=!#$%^*\'+\\={}|?~])>/', $sender, $matches)) {
            # Match address in the form: Name <email@host>
            $result[0] = $matches[2];
            $result[1] = $matches[sizeof($matches) - 1];
        } elseif (preg_match('/([\w\-=!#$%^*\'+\\.={}|?~]+@[\w\-=!#$%^*\'+\\.={}|?~]+[\w\-=!#$%^*\'+\\={}|?~])\s+\((.*)\)/', $sender, $matches)) {
            # Match address in the form: email@host (Name)
            $result[0] = $matches[1];
            $result[1] = $matches[2];
        } else {
            # Only the email address present
            $result[0] = $sender;
            $result[1] = $sender;
        }

        $result[0] = str_replace("\"", "", $result[0]);
        $result[0] = str_replace("'", "", $result[0]);

        return $result;
    }

    protected function decodeBody($input, $encoding = '7bit')
    {
        switch (strtolower($encoding)) {
            case '7bit':
                return $input;
                break;

            case 'quoted-printable':
                return $this->quotedPrintableDecode($input);
                break;

            case 'base64':
                return base64_decode($input);
                break;

            default:
                return $input;
        }
    }

    protected function quotedPrintableDecode($input)
    {
        // Remove soft line breaks
        $input = preg_replace("/=\r?\n/", '', $input);

        // Replace encoded characters
        $input = preg_replace_callback('/=([a-f0-9]{2})/i', [$this, 'quotedPrintableDecodeHandler'], $input);

        return $input;
    }

    protected function quotedPrintableDecodeHandler($m)
    {
        return chr(hexdec($m[1]));
    }
}
