Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
205 / 205
EmailException
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 __construct
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
Email
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
15 / 15
118
100.00% covered (success)
100.00%
203 / 203
 __construct
100.00% covered (success)
100.00%
1 / 1
6
100.00% covered (success)
100.00%
9 / 9
 getBackEnd
100.00% covered (success)
100.00%
1 / 1
17
100.00% covered (success)
100.00%
15 / 15
 get
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
4 / 4
 to
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 replyTo
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 cc
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 bcc
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 subject
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 message
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 template
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 attachFile
100.00% covered (success)
100.00%
1 / 1
7
100.00% covered (success)
100.00%
10 / 10
 attachData
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
6 / 6
 send
100.00% covered (success)
100.00%
1 / 1
62
100.00% covered (success)
100.00%
121 / 121
 address
100.00% covered (success)
100.00%
1 / 1
7
100.00% covered (success)
100.00%
14 / 14
 cronMinute
100.00% covered (success)
100.00%
1 / 1
6
100.00% covered (success)
100.00%
9 / 9
<?php
/**
 *  PHP Portal Engine v3.0.0
 *  https://github.com/bztsrc/phppe3/.
 *
 *  Copyright LGPL 2016 bzt
 *
 *  This program is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU Lesser General Public License as published
 *  by the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU Lesser General Public License for more details.
 *
 *   <http://www.gnu.org/licenses/>
 *
 * @file vendor/phppe/Core/libs/Email.php
 *
 * @author bzt
 * @date 1 Jan 2016
 * @brief Email message object, included in Pack
 * This class is not as flexible as it's competition, but smart
 * enough to work in 99% of usecases.
 */
namespace PHPPE;
/**
 * Exception class.
 */
class EmailException extends \Exception
{
    public function __construct($message = '', $code = 0, Exception $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
}
class Email extends Extension
{
    public $name;
    //! smtp relay parameters
    private $via;
    private static $host;
    private static $port;
    private static $user;
    private static $pass;
    private static $sender;
    private static $forge;
    //! email object properties
    private $header;
    private $message;
    private $attach;
    /**
     * constructor. You can pass a previosly dumped object to it.
     *
     * @param string    object data dumped by get()
     * @param string    hostname
     */
    public function __construct($msg = '')
    {
        //! use Core's smtp relay configuration
        //! we allow an array here, to specify additional fields in config.php
        $cfg = is_array(Core::$core->mailer) ? Core::$core->mailer : @parse_url(Core::$core->mailer);
        $this->getBackEnd($cfg);
        //! load data from dumped object
        if (!empty($msg)) {
            $msg = json_decode($msg, true);
            if (json_last_error() || !is_array($msg)) {
                throw new EmailException(json_last_error_msg());
            }
            foreach (['header', 'message', 'attach'] as $k) {
                $this->$k = $msg[$k];
            }
        }
    }
    private function getBackEnd($cfg)
    {
        //! populate properties
        $p = !empty($cfg['protocol']) ? $cfg['protocol'] : (!empty($cfg['scheme']) ? $cfg['scheme'] : '');
        if (empty($p) && is_string($cfg)) $p = $cfg;
        if (empty($p) && !empty($cfg['path'])) $p = $cfg['path'];
        $this->via = !empty($p) && in_array($p, [
            //! backends
            'smtp',        //! speak smtp directly (no dependency at all, default)
            'mime',        //! just build mime message and return it
            'log',        //! only log message but do not send it for real
            'mail',        //! use php's mail()
            'sendmail',    //! use sendmail command through pipe
            'db',        //! store message in database queue
            'phpmailer',    //! use PHPMailer class for sending
        ]) ? $p : '';
        self::$host = !empty($cfg['host']) ? $cfg['host'] : 'localhost';
        self::$port = !empty($cfg['port']) ? $cfg['port'] : 25;
        self::$user = !empty($cfg['user']) ? $cfg['user'] : '';
        self::$pass = !empty($cfg['pass']) ? $cfg['pass'] : '';
        self::$sender = !empty($cfg['sender']) ? $cfg['sender'] :
            'no-reply@'.(!empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] :
            (!empty(Core::$core->hostname) ? Core::$core->hostname : 'localhost'));
        self::$forge = !empty($cfg['forge']) ? $cfg['forge'] : '';
    }
    /**
     * you can call this to get email in a form that can be stored in database.
     *
     * @return string   dumped email object
     */
    public function get()
    {
        return json_encode([
            'header' => $this->header,
            'message' => $this->message,
            'attach' => $this->attach,
        ]);
    }
    /**
     * methods to add headers to email.
     */
    public function to($email)
    {
        $this->address($email, 'To');
        return $this;
    }
    public function replyTo($email)
    {
        $this->address($email, 'Reply-to');
        return $this;
    }
    public function cc($email)
    {
        $this->address($email, 'Cc');
        return $this;
    }
    public function bcc($email)
    {
        $this->address($email, 'Bcc');
        return $this;
    }
    public function subject($subject)
    {
        $this->header['Subject'] = '=?utf-8?Q?'.quoted_printable_encode(str_replace("\r", '', str_replace("\n", ' ', $subject))).'?=';
        return $this;
    }
    public function message($message)
    {
        $this->message = trim(str_replace("\r", '', $message));
        return $this;
    }
    public function template($template, $args)
    {
        View::assign('args', $args);
        $this->message = trim(str_replace("\r", '', View::template($template)));
        return $this;
    }
    /**
     * methods to add attachments to email.
     */
    public function attachFile($file, $mime = '')
    {
        if (!empty($file)) {
            $fn = "";
            foreach ([$file, '.tmp/'.$file, 'data/'.$file, 'vendor/phppe/*/'.$file] as $v) {
                $fn = @glob($v)[0];
                if(!empty($fn)) break;
            }
            if(!empty($fn))
                $this->attach[] = [
                    'file' => basename($file),
                    'mime' => !empty($mime) && strpos($mime, '/') ? $mime : mime_content_type($fn),
                ];
        }
        return $this;
    }
    public function attachData($data, $mime = '', $filename = '')
    {
        if (!empty($data)) {
            $this->attach[] = [
                'file' => !empty($filename) ? $filename : '',
                'mime' => !empty($mime) && strpos($mime, '/') ? $mime : 'application/octet-stream',
                'data' => $data, ];
        }
        return $this;
    }
    /**
     * construct mime email and send it out.
     *
     * @param string    transport backend
     */
    public function send($via = '')
    {
        //! allow temporarly override backend. Only url allowed, not array
        if (!empty($via)) {
            $this->getBackEnd(@parse_url($via));
        }
        //! sanity checks
        if (empty($this->via)) {
            throw new EmailException(L('Mailer backend not configured!'));
        }
        if (empty($this->message)) {
            throw new EmailException(L('Empty message!'));
        }
        if (empty($this->header['Subject'])) {
            throw new EmailException(L('No subject given!'));
        }
        if (empty($this->header['To'])) {
            throw new EmailException(L('No recipient given!'));
        }
        if (count($this->header['To']) > 64) {
            // @codeCoverageIgnoreStart
            throw new EmailException(L('Too many recipients!'));
        }
            // @codeCoverageIgnoreEnd
        $this->address(self::$sender, 'From');
        $local = @explode('@', array_keys($this->header['From'])[0])[1];
        if (empty($local)) $local = 'localhost';
        $id = sha1(uniqid()).'_'.microtime(true).'@'.$local;
        //! message type
        $isHtml = preg_match('/<html/i', $this->message);
        //! *** handle transport backends that does not require mime message ***
        if ($this->via == 'db') {
            //! mail queue in database
            if (empty(DS::db())) {
                throw new EmailException(L('DB queue backend without configured datasource!'));
            }
            return DS::exec('INSERT INTO email_queue (data,created) VALUES (?,?);', [$this->get(), Core::$core->now]) > 0 ? true : false;
        } elseif ($this->via == 'phpmailer') {
            //! PHP Mailer
            if (!ClassMap::has('PHPMailer')) {
                throw new EmailException(L('PHPMailer not installed!'));
            }
            // @codeCoverageIgnoreStart
            $mail = new \PHPMailer();
            $mail->Subject = $this->header['Subject'];
            $mail->SetFrom(implode(', ', $this->header['From']));
            if (!empty($this->header['Reply-To'])) {
                $mail->AddReplyTo($this->header['Reply-To']);
            }
            foreach (['To', 'Cc', 'Bcc'] as $type) {
                foreach ($this->header[$type] as $rcpt => $full) {
                    list($name) = explode('<', $full);
                    $mail->SetAddress(self::$forge ? self::$forge : $rcpt, trim($name));
                }
            }
            foreach ($this->attach as $attach) {
                $mail->AddAttachment($attach['file']);
            }
            $mail->MsgHTML($this->message);
            return $mail->Send();
            // @codeCoverageIgnoreEnd
        }
        //! *** build mime message ***
        //! mime headers
        $headers['MIME-Version'] = '1.0';
        $headers['Content-Class'] = 'urn:content-classes:message';
        $headers['Content-Type'] = 'text/plain;charset=utf-8';
        $headers['Content-Transfer-Encoding'] = '8bit';
        $headers['Sender'] = implode(', ', $this->header['From']);
        $headers['Message-ID'] = '<'.$id.'>';
        $headers['Date'] = date('r', Core::$core->now);
        $headers['X-Mailer'] = 'PHPPE '.VERSION;
        foreach ($this->header as $k => $v) {
            $headers[$k] = is_array($v) ? implode(', ', $v) : $v;
        }
        //! mime body
        if (!$isHtml) {
            //! plain text email
            $message = wordwrap($this->message, 78);
        } else {
            $boundary = uniqid();
            //! html email with a plain text alternative
            $headers['Content-Type'] = "multipart/alternative;\n boundary=\"".$boundary.'"';
            $message = "This is a multi-part message in MIME format.\r\n";
            $message .= '--'.$boundary."\n".
                "Content-type: text/plain;charset=utf-8\n".
                "Content-Transfer-Encoding: 8bit\n\n".
                wordwrap(
                preg_replace("/\<.*?\>/m", '',
                strtr($this->message, [
                    '</h1>' => "\n\n",
                    '</h2>' => "\n\n",
                    '</h3>' => "\n\n",
                    '</h4>' => "\n\n",
                    '</h5>' => "\n\n",
                    '</h6>' => "\n\n",
                    '</p>' => "\n\n",
                    '</td>' => "\t",
                    '</tr>' => "\n",
                    '</table>' => "\n",
                    '<br>' => "\n",
                    '<br/>' => "\n",
                ])), 78)."\r\n";
            //! look for images in html, if found, we have to create a multipart/related block
            if (preg_match_all("/(http|images\/|data\/).*?\.(gif|png|jpe?g)/mis", $this->message, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
                $boundary2 = uniqid();
                $diff = 0;
                foreach ($m as $k => $c) {
                    //if it's a absolute url, don't replace it
                    if ($c[1][0] == 'http') {
                        unset($m[$k]);
                        continue;
                    }
                    //generate a cid
                    $m[$k][3] = uniqid();
                    //get local path for filename
                    if ($c[1][0] == 'data/' && file_exists($c[0][0])) $m[$k][4] = $c[0][0];
                    elseif (file_exists('public/'.$c[0][0])) $m[$k][4] = 'public/'.$c[0][0];
                    else {
                        foreach (['vendor/phppe/*/', 'vendor/*/', 'vendor/*/*/'] as $d) {
                            if ($m[$k][4] = @glob($d.$c[0][0])[0]) {
                                break;
                            }
                        }
                    }
                    //replace image url in message
                    $new = 'cid:'.$m[$k][3];
                    $this->message =
                        substr($this->message, 0, $c[0][1] + $diff).
                        $new.
                        substr($this->message, $c[0][1] + $diff + strlen($c[0][0]));
                    $diff -= strlen($c[0][0]) - strlen($new);
                }
            }
            if (!empty($m)) {
                //! add the html part as related
                $message .= '--'.$boundary."\n".
                    "Content-type: multipart/related;\n boundary=\"".$boundary2."\"\n\n".
                    "This is a multi-part message in MIME format.\r\n--".$boundary2."\n".
                    "Content-Type: text/html;charset=utf-8\n".
                    "Content-Transfer-Encoding: 8bit\n\n".
                    wordwrap($this->message, 78)."\r\n";
                foreach ($m as $c) {
                    $data = empty($c[4]) ? '' : (substr($c[4], 0, 4) == 'http' ? Core::get($c[4]) : file_get_contents($c[4]));
                    if (!$data) continue;
                    //get content
                    $message .= '--'.$boundary2."\n".
                        'Content-Type: image/'.($c[2][0] == 'jpg' ? 'jpeg' : $c[2][0])."\n".
                        "Content-Transfer-Encoding: base64\n".
                        "Content-Disposition: inline\n".
                        'Content-ID: <'.$c[3].">\n\n".
                        chunk_split(base64_encode($data), 78, "\n");
                }
                $message .= '--'.$boundary2."--\n";
            } else {
                $message .= '--'.$boundary."\n".
                    "Content-type: text/html;charset=utf-8\n".
                    "Content-Transfer-Encoding: 8bit\n\n".
                    wordwrap($this->message, 78)."\r\n";
            }
            $message .= '--'.$boundary."--\n";
        }
        if (!empty($this->attach)) {
            $boundary = uniqid();
            $headers['Content-Type'] = "multipart/mixed;\n boundary=\"".$boundary.'"';
            $message = "This is a multi-part message in MIME format.\r\n--".$boundary."\n".$message;
            foreach ($this->attach as $attach) {
                $data = !empty($attach['data']) ? $attach['data'] : (substr($attach['file'], 0, 4) == 'http' ? Core::get($attach['file']) : @file_get_contents(substr($attach['file'], 0, 6) == 'images' ? @glob('vendor/phppe/*/'.$attach['file'])[0] : $attach['file']));
                if (!$data) continue;
                $message .= '--'.$boundary."\n".
                    'Content-type: '.(!empty($attach['mime']) ? $attach['mime'] : 'application-octet-stream')."\n".
                    'Content-Disposition: attachment'.(!empty($attach['file']) ? ";\n filename=\"".basename($attach['file']).'"' : '')."\n".
                    "Content-Transfer-Encoding: base64\n\n".
                    chunk_split(base64_encode($data), 78, "\n");
            }
            $message .= '--'.$boundary."--\n";
        }
        //! flat headers
        $header = '';
        //! redirect message to a specific address (for testing)
        if (!empty(self::$forge)) {
            // @codeCoverageIgnoreStart
            $headers['To'] = self::$forge;
            $headers['Cc'] = '';
            $headers['Bcc'] = '';
            // @codeCoverageIgnoreEnd
        }
        foreach ($headers as $k => $v) {
            $header .= $k.': '.$v."\r\n";
        }
        //! log that we are sending a mail
        Core::log('I', 'To: '.$headers['To'].', Subject: '.$headers['Subject'].', ID: '.$id, 'email');
        //if email directory exists, save the full mime message as well for debug
        @file_put_contents('data/log/email/'.$id, 'Backend: '.$this->via.' '.self::$user.':'.self::$pass.'@'.self::$host.':'.self::$port."\r\n\r\n".$header."\r\n".$message);
        //! *** handle transport backends ***
        switch ($this->via) {
            //! only log and possibly save message in file, do not send for real. Nothing left to do
            case 'log': break;
            //! return constructed mime message
            case 'mime': return $header."\r\n".$message; break;
            //! use php's mail()
            case 'mail': {
                $to = $headers['To'];
                $subj = $headers['Subject'];
                unset($headers['To']);
                unset($headers['Subject']);
                $header = '';
                foreach ($headers as $k => $v) {
                    $header .= $k.': '.$v."\r\n";
                }
                if (!mail($to, $subj, $message, $header)) {
                    Core::log('E', 'mail() failed, To: '.$to.', Subject: '.$subj.', ID: '.$id, 'email');
                    return false;
                }
            // @codeCoverageIgnoreStart
            } break;
            //! sendmail through pipe
            case 'sendmail': {
                $f = @popen('/usr/sbin/sendmail -t -i', 'w');
                if ($f) {
                    fputs($f, $header."\r\n".$message);
                    pclose($f);
                } else {
                    Core::log('E', 'mail() failed, To: '.$headers['To'].', Subject: '.$headers['Subject'].', ID: '.$id, 'email');
                    return false;
                }
            } break;
            //! this is how real programmers do it, let's speak smtp directly!
            default: {
                //open socket
                $s = @fsockopen(self::$host, self::$port, $en, $es, 5);
                $l = '';
                //get welcome message
                if ($s) {
                    stream_set_timeout($s, 5);
                    $l = fgets($s, 1024);
                }
                if (!$s || substr($l, 0, 3) != '220') {
                    Core::log('E', 'connection error to '.self::$host.':'.self::$port.', '.trim($l), 'email');
                    return false;
                }
                //we silently assume we got 8BITMIME here, it's a safe assumption as of 2016
                while ($l[3] == '-') {
                    $l = fgets($s, 1024);
                }
                //greet remote
                fputs($s, 'EHLO '.$local."\r\n");
                $l = fgets($s, 1024);
                while ($l[3] == '-') {
                    $l = fgets($s, 1024);
                }
                //tell who are sending
                fputs($s, 'MAIL FROM: <'.array_keys($this->header['From'])[0].">\r\n");
                $l = fgets($s, 1024);
                if (substr($l, 0, 3) != '250') {
                    PPHPE3::log('E', 'from error: '.trim($l), 'email');
                    return false;
                }
                //to whom
                $addresses = array_merge(array_keys($this->header['To']), array_keys($this->header['Cc']), array_keys($this->header['Bcc']));
                foreach ($addresses as $a) {
                    fputs($s, 'RCPT TO: <'.$a.">\r\n");
                    $l = fgets($s, 1024);
                    if (substr($l, 0, 3) != '250') {
                        Core::log('E', 'recipient error: '.trim($l), 'email');
                    }
                }
                //the message
                fputs($s, "DATA\r\n");
                $l = fgets($s, 1024);
                if (substr($l, 0, 3) != '250') {
                    Core::log('E', 'data error: '.trim($l), 'email');
                    return false;
                }
                fputs($header."\r\n".str_replace(array("\n.\n", "\n.\r"), array("\n..\n", "\n..\r"), $message)."\r\n.\r\n");
                $l = fgets($s, 1024);
                if (substr($l, 0, 3) != '250') {
                    Core::log('E', 'data send error: '.trim($l), 'email');
                    return false;
                }
                //say bye
                fputs($s, "QUIT\r\n");
                fclose($s);
            // @codeCoverageIgnoreEnd
            }
        }
        return true;
    }
    /**
     * validate and shape an email address.
     *
     * @param string    email address (in either "name <account@domain>", "account@domain", "name <account@[ip address]>" or "account@[ip address]" format
     * @param string    header type to add address to (To, Cc, Bcc)
     */
    private function address($email, $type = 'To')
    {
        //! check if it's a valid email address
        if (preg_match("/(.*?)?[\<]?(([^\<]+)\@((\[?)[a-zA-Z0-9\-\.\:\_]+([a-zA-Z]+|[0-9]{1,3})(\]?)))[\>]?$/", $email, $m)) {
            //! only localhost allowed not to contain dot
            if (strpos($m[4], '.') === false && $m[4] != 'localhost') {
                throw new EmailException(L('invalid email address').': '.$email);
            }
            //! remove if it's already exists in headers to avoid duplications
            foreach (['To', 'Cc', 'Bcc'] as $rcpt) {
                if (!empty($this->header[$rcpt][$m[2]])) {
                    unset($this->header[$rcpt][$m[2]]);
                }
            }
            //! add to headers
            $this->header[$type][$m[2]] = '=?utf-8?Q?'.
                quoted_printable_encode(
                str_replace("\r", '',
                str_replace("\n", ' ',
                str_replace('@', ' AT ', !empty($m[1]) ? trim($m[1]) : $m[3])))).
                '?= <'.$m[2].'>';
            return true;
        }
        throw new EmailException(L('invalid email address').': '.$email);
    }
    /**
     * Cron job to read mails from queue and send them out
     */
    public function cronMinute($item)
    {
        //! get real mailer backend ($core->mailer points to db queue backend)
        // @codeCoverageIgnoreStart
        if (empty(Core::$core->realmailer)) {
            Core::log('C', L('Real mailer backend not configured!'));
        }
        // @codeCoverageIgnoreEnd
        //! get items from database
        $lastId = 0;
        while ($row = DS::fetch('*', 'email_queue', 'id>?', '', 'id ASC', [$lastId])) {
        if(empty($row->id))
        break;
            $email = new self($row->data);
            $lastId = $row->id;
            try {
                if (!$email->send(Core::$core->realmailer)) {
                    // @codeCoverageIgnoreStart
                    throw new \Exception('send() returned false');
                }
                DS::exec('DELETE FROM email_queue WHERE id=?;', [$row->id]);
            } catch (\Exception $e) {
                Core::log('E', sprintf(L('Unable to send #%s from queue'),$row->id).': '.$e->getMessage());
            }
                    // @codeCoverageIgnoreEnd
            sleep(1);
        }
    }
}