Location: PHPKode > projects > phlyMail Lite > phlymail/shared/lib/phm_streaming_smtp.php
<?php
/**
 * SMTP client class
 * supports transparent SSL connection, if openSSL extension is installed
 *
 * @package phlyMail Nahariya 4.0+
 * @subpackage Email handler
 * @subpackage Network client connectivity
 * @author Matthias Sommerfeld <hide@address.com>
 * @copyright 2003-2010 phlyLabs, Berlin (http://phlylabs.de)
 * @version 1.0.7 2010-10-31
 * @todo more tolerant against bogus servers (some use the optional initial-response argument for AUTH PLAIN, others do not)
 * @todo Implement DIGEST-MD5 SASL mechanism
 */

class phm_streaming_smtp
{
    public $CRLF = "\r\n"; // Define standard line endings (CRLF and LF)
    public $LF   = "\n";
    public $error = false; // Init Error (query with $this->get_last_error()
    public $error_nl = 'LF'; // Multiple errors can be returned in either HTML linebreaks or plain LF
    public $authonly = false; // If set to yes, only valid SMTP AUTH connections will be used
    public $_diag_session = false; // Switch this to true for writing the session to a diagnosis file
    public $server = false;
    public $port = false;
    public $smtp = false;

    protected $def_port = 25; // Default port number to use, if not specified
    protected $_SASL = array('cram_sha256', 'cram_sha1', 'cram_md5', 'login', 'plain'); // List of SASL mechanisms we support
    protected $has_tls = false;
    protected $may_tls = true;
    protected $is_tls = false;

    private $SrvMaxSize = 0;
    private $SrvAuthMech = array();

    /**
     * The phm_streaming_smtp constructor method
     *
     * If called with a server name [and optionally a port number], it tries to
     * connect to that specific server immediately. If called without arguments,
     * you can connect by actually calling the method open_server() and let this
     * negotiate the correct server by itself.
     * If you pass a username and a password here, these will be used for
     * SMTP AUTH (if supported by server).
     *
     * @param string  Servername or IP address
     *[@param integer Port number, Default: 25]
     *[@param string  Username for SMTP AUTH]
     *[@param string  Password for SMTP AUTH]
     *[@param bool  Set to false, to disallow the use of TLS]
     */
    public function __construct($server = '', $port = 25, $username = false, $password = false, $allowtls = true)
    {
        if ($this->_diag_session) $this->diag = fopen(dirname(__FILE__).'/smtpdiag.txt', 'w');
        $this->may_tls = $allowtls;
        if ($server != '') {
            if ($this->_connect($server, $port)) {
                $this->server = $server;
                $this->port = $port;
                if ($username) $this->username = $username;
                if ($password) $this->password = $password;
                return true;
            }
            return false;
        }
        return true;
    }

    /**
     * Sets a new option value. Available options and values:
     * [authonly - use SMTP AUTH only ('yes', 'no')]
     * [error_nl - use HTML linebreaks ('HTML') or plain LF ('LF')]
     *
     * @param string Parameter to set
     * @param string Value to use
     * @return boolean TRUE on success, FALSE otherwise
     * @access public
     */
    public function set_parameter($option, $value = false)
    {
        switch ($option) {
        case 'authonly':
            $this->authonly = (bool) $value;
            break;
        case 'error_nl':
            $this->error_nl = ('HTML' == $value) ? 'HTML' : 'LF';
            break;
        default:
            $this->error .= 'Unknown option '.$option.$this->LF;
            return false;
        }
        return true;
    }

    /**
     * Read out the last error that occured
     *
     * @param void
     * @return string Returns the last error, if one exists, else an emtpy string
     * @access public
     */
    public function get_last_error()
    {
        $error = ($this->error) ? $this->error : '';
        $this->error = false;
        return ($this->error_nl == 'HTML') ? nl2br($error) : $error;
    }

    /**
     * Open a server connection
     *
     * If you've specified username and password on construction, these will be used here,
     * if you specified no server and port on construction, this method will negotiate
     * the server to be used by querying the MX root record for the first TO: address
     * passed.
     * Be aware, that using multiple TO: addresses with a negotiated SMTP server might
     * result in TO: addresses rejected due to server's No-Relay policy
     * This method makes use of the "authonly" setting
     *
     * @param string  FROM: address
     * @param array  TO: address(es)
     * @param int  Size of the message to be transferred in octets
     * @return boolean Returns TRUE on success, FALSE otherwise
     * @access public
     */
    public function open_server($from = false, $to = false, $size = 0)
    {
        if (!$from) {
            $this->error .= 'You must specify a from address'.$this->LF;
            return false;
        }
        if (!$to) {
            $this->error .= 'You must specify at least one recipient address'.$this->LF;
            return false;
        }
        if (!is_array($to)) $to = array($to);

        list(,$this->helodomain) = explode('@', $from);

        if ($this->server) {
            // We either use the global setting for the server to use (if given)...
            $mx[0] = &$this->server;
            $port[0] = isset($this->port) ? $this->port : $this->def_port;
            $user[0] = isset($this->username) ? $this->username : false;
            $pass[0] = isset($this->password) ? $this->password : false;
        } else {
            // ... or try to negotiate on our own
            list(,$querydomain) = explode('@', $to[0], 2);
            // On Windows systems this function is not available
            if (!function_exists('getmxrr')) {
                $this->error .= 'No SMTP servers for '.$querydomain.' found'.$this->LF;
                return false;
            }
            if (getmxrr($querydomain, $mx, $weight) == 0) {
                $this->error .= 'No SMTP servers for '.$querydomain.' found'.$this->LF;
                return false;
            }
            array_multisort($mx, $weight);
        }
        // Now trying to find one server to talk to... first come, first serve
        foreach ($mx as $id => $host) {
            if (!isset($port[$id])) $port[$id] = $this->def_port;
            // If we can't connect, try next server in list
            if (!$this->smtp && !$this->_connect($host, $port[$id])) continue;
            /**
             * Some servers, namely the qmail ones reject the first line, so we simply try to cycle through
             * the first handshake twice. In case of normal servers we break out of this loop once the
             * handshake was successfull. Additionally this loop is used to try starting a TLS connection,
             * which requires us to run through the EHLO/HELO dialogue twice - once to find out, whether the server
             * has TLS enabled, the second time directly after sending STARTTLS.
             */
            for ($i = 0; $i < 2; ++$i) {
                // Handshake with the server, identify ourselves, get capabilities
                $response = $this->talk('EHLO '.$this->helodomain);
                if (substr($response, 0, 3) == '250') {
                    // Server supports SMTP AUTH... try the supported mechanisms to authenticate
                    $supported = $this->get_supported_sasl_mechanisms($response);
                    // Find the mechanisms supported on both sides
                    $SASL = array_intersect($this->_SASL, $supported);
                    // RFC1870
                    $this->SrvMaxSize = $this->get_server_maxsize($response);
                    // Server supports TLS, PHP has it, too -> try switching over to it
                    if (!$this->is_tls && $this->has_tls && $this->get_server_has_tls($response)) {
                        $response = $this->talk('STARTTLS');
                        if (!stream_socket_enable_crypto($this->smtp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
                            $this->close();
                            $this->set_error('Cannot enable TLS, although server advertises it');
                            $this->has_tls = false;
                            continue;
                        } else {
                            $this->is_tls = true;
                            $i--; // After establishing TLS we have to restart the EHLO / HELO dialogue
                            continue;
                        }
                    }
                    break;
                } else {
                    if ($i == 0) continue;
                    $response = $this->talk('HELO '.$this->helodomain);
                    if (substr($response, 0, 3) != '250') {
                        $this->close();
                        $this->error .= 'HELO '.$this->helodomain.' failed. Aborting connection'.$this->LF;
                        continue 2;
                    }
                    $SASL = array();
                    $this->SrvMaxSize = 0;
                }
            }
            // Server supports RFC1870 (SIZE extension) and mail size has been given
            if ($size && $this->SrvMaxSize && $size > $this->SrvMaxSize) {
                $this->close();
                $this->error .= 'Message size exceeds server\'s known upper limit'.$this->LF;
                continue;
            }
            // We've got credentials and try to use SMTP AUTH
            if (isset($user[$id]) && $user[$id]) {
                $this->is_auth = false;
                foreach ($SASL as $v) {
                    $function_name = '_auth_'.$v;
                    if ($this->{$function_name}($user[$id], $pass[$id])) {
                        $this->error = false;
                        $this->is_auth = true;
                        break;
                    }
                }
                if (!$this->is_auth && $this->authonly) {
                    $this->close();
                    $this->error .= 'SMTP-AUTH failed. Aborting connection'.$this->LF;
                    return false;
                }
            }
            if ($this->init_mail_transfer($from, $to, $size)) return true;
        }
        return false;
    }

    protected function init_mail_transfer($from = false, $to = false, $size = 0)
    {
        if (!$from) {
            $this->error .= 'You must specify a from address'.$this->LF;
            return false;
        }
        if (!$to) {
            $this->error .= 'You must specify at least one recipient address'.$this->LF;
            return false;
        }
        if (!is_array($to)) $to = array($to);

        $response = $this->talk('MAIL FROM: <'.$from.'>'.($size && $this->SrvMaxSize ? ' SIZE='.intval($size) : ''));
        if (substr($response, 0, 3) != '250') {
            $this->close();
            if (substr($response, 0, 3) == '452') {
                $this->error .= $response.$this->LF;
            } elseif (substr($response, 0, 3) == '552') {
                $this->error .= $response.$this->LF;
            } else {
                $this->error .= 'FROM address '.$from.' rejected by server: '.$response.$this->LF;
            }
            return false;
        }
        $accepted = 0;
        foreach ($to as $k => $val) {
            $response = $this->talk('RCPT TO: <'.$val.'>');
            // All return codes of 25* mean, that the address is accepted
            if (substr($response, 0, 2) == '25') $accepted = 1;
            else $failed[] = $this->LF.'- '.$val.': '.trim($response);
        }
        if (0 == $accepted) {
            $this->close();
            $this->error .= 'None of the TO addresses were accepted: '.implode(',', $failed).$this->LF;
            return false;
        }
        $response = $this->talk('DATA');
        if (substr($response, 0, 3) != '354') {
            $this->close();
            $this->error .= 'Server rejected the DATA command: '.trim($response).$this->LF;
            return false;
        } else {
            if (isset($failed)) {
                $this->error .= 'Some of the TO addresses were rejected: '.implode(',', $failed).$this->LF;
            }
            return true;
        }
    }

    /**
     * Write to the SMTP stream opened before by open_server()
     *
     * @param string Line of data to put to the stream
     * @return boolean Returns TRUE on success, FALSE otherwise
     * @access public
     */
    public function put_data_to_stream($line = false)
    {
        if (!is_resource($this->smtp)) return false;
        if (!$line) return false;
        $line = rtrim($line, CRLF);
        if ($line == '.') $line = '..';
        fwrite($this->smtp, $line.$this->CRLF);
        if ($this->_diag_session) fwrite($this->diag, 'C:'.$line);
        return true;
    }

    /**
     * Finishing a mail transfer to the server
     * Use this method, if your application doesn't automatically
     * put the final CRLF.CRLF to the SMTP stream after
     * putting al the mail data to it.
     * This method implicitly calls check_success().
     *
     * @param  void
     * @return boolean Return state of check_success()
     * @access public
     */
    public function finish_transfer()
    {
        fwrite($this->smtp, $this->CRLF.'.'.$this->CRLF);
        if ($this->_diag_session) fwrite($this->diag, 'C: '.$this->CRLF.'C: .'.$this->CRLF);
    }

    /**
     * Call this method after putting your last mail line to the server
     *
     * @param void
     * @return boolean Returns TRUE on success, FALSE otherwise
     * @access public
     */
    public function check_success()
    {
        if (!is_resource($this->smtp) || feof($this->smtp)) {
            $line = '999 SMTP server connection already died';
        } else {
            $line = fgets($this->smtp, 4096);
            if ($this->_diag_session) fwrite($this->diag, 'S: '.$line);
        }
        if (substr($line, 0, 3) != '250') {
            $this->error .= 'Wrong DATA: '.trim($line).$this->LF;
            return false;
        }
        return true;
    }

    /**
     * Talk to the SMTP server directly (for things not covered by this class)
     *
     * @param  string Command to pass to the server
     * @return string Answer of the server
     * @access public
     */
    public function talk($input, $oneliner = false)
    {
        if ($this->_diag_session) fputs($this->diag, 'C: '.$input.$this->CRLF);
        $output = false;
        fputs($this->smtp, $input.$this->CRLF);
        $end = 0;
        while (0 == $end && is_resource($this->smtp)) {
            $line = fgets($this->smtp, 4096);
            if ($this->_diag_session) fputs($this->diag, 'S: '.$line);
            if ($oneliner) $end = 1;
            if (' ' == substr($line, 3, 1)) $end = 1;
            $output .= $line;
        }
        return $output;
    }

    /**
     * Close a previously opened connection
     * Although it doesn't return you something, you can query the state by using
     * get_last_error()
     *
     * @param  void
     * @return void
     * @access public
     */
    public function close()
    {
        if ($this->_diag_session) fclose($this->diag);
        if (is_resource($this->smtp)) {
            $this->talk('QUIT');
            fclose($this->smtp);
            $this->smtp = false;
            $this->error .= 'Connection closed'.$this->LF;
        } else {
            $this->error .= 'No connection to close. Did nothing.'.$this->LF;
        }
    }

    /**
     * Open socket to an SMTP server
     *
     * @param    string    Server name or IP address
     * @param    integer   Port number
     * @return   boolean   TRUE on success, FALSE otherwise
     * @access   private
     */
    private function _connect($server, $port)
    {
        $response = false;
        $ssl_capable = function_exists('extension_loaded') && extension_loaded('openssl');
        $this->has_tls = $this->may_tls ? (function_exists('stream_socket_enable_crypto')) : false;
        if ($port == 465) {
            if (!$ssl_capable) {
                $port = 25;
            } else {
                $server = (substr($server, 0, 6) == 'ssl://') ? $server : 'ssl://'.$server;
            }
        } elseif (substr($server, 0, 6) == 'ssl://') {
            if (!$ssl_capable) $server = str_replace('ssl://', '', $server);
        }
        $smtp = @fsockopen($server, $port, $errno, $errstr, 15);
        if (!$smtp) {
            $this->error .= 'No connect to '.$server.':'.$port.' ('.$errno.' '.$errstr.')'.$this->LF;
            return false;
        }
        $this->smtp = $smtp;

        $end = 0;
        while (0 == $end) {
            $line = fgets($this->smtp, 4096);
            if (' ' == substr($line, 3, 1)) $end = 1;
            $response .= $line;
        }
        if (!$response || substr($response, 0, 3) != '220') {
            $this->close();
            $this->error .= 'Connecting to '.$server.':'.$port.' failed ('.$response.')'.$this->LF;
            return false;
        }
        return true;
    }

    /**
     * Find out about SASL mechanisms a specific SMTP server supports
     *
     * @param    string    Server answer to EHLO command
     * @return   array     list of supported SASL mechanisms
     * @access   private
     */
    private function get_supported_sasl_mechanisms($response)
    {
        if (preg_match('!^250(\ |\-)AUTH(\ |\=)([\w\s-_]+)$!Umi', $response, $found)) {
            $found[3] = strtolower(str_replace('-',  '_',  trim($found[3])));
            return explode(' ', $found[3]);
        }
        return array();
    }

    /**
     * Negotiate, whether this server supports RFC1870 (Message Size Declaration)
     *
     * @param    string    Server answer to EHLO command
     * @return   int    maximum size, defaults to 0 if not known or not supported
     * @access   private
     */
    private function get_server_maxsize($response)
    {
        if (preg_match('!^250(\ |\-)SIZE(\ |\=)([0-9]+)$!Umi', $response, $found)) {
            return $found[3];
        }
        return 0;
    }

    private function get_server_has_tls($response)
    {
        return preg_match('!^250(\ |\-)STARTTLS!Umi', $response);
    }

    /**
     * Implementation of SASL mechanism CRAM-MD5
     *
     * @param    string    Username
     * @param    string    Password
     * @return   boolean   TRUE on successful authentication, FALSE otherwise
     * @access   private
     */
    private function _auth_cram_md5($user = '', $pass = '')
    {
        // See RFC2104 (HMAC, also known as Keyed-MD5)
        $response = $this->talk('AUTH CRAM-MD5');
        if (substr($response, 0, 3) == '334') {
            // Get the challenge from the server
            $challenge = base64_decode(substr(trim($response), 4));
            // Secret to use
            $secret = $pass;
            // Rightpad with NUL bytes to have 64 chars
            if (strlen($secret) < 64) $secret = $secret.str_repeat(chr(0x00), 64 - strlen($secret));
            // In case, the secret is longer than 64 chars, md5() it
            if (strlen($secret) > 64) $secret = md5($secret);

            $ipad = str_repeat(chr(0x36), 64);
            $opad = str_repeat(chr(0x5c), 64);
            $shared = bin2hex(pack('H*', md5(($secret ^ $opad).pack('H*', md5(($secret ^ $ipad).$challenge)))));

            $response = $this->talk(base64_encode($user.' '.$shared));
            if (substr($response, 0, 3) != '334' && substr($response, 0, 3) != '235') {
                $this->error .= 'AUTH CRAM-MD5 failed:'.trim($response).$this->LF;
                return false;
            }
            return true;
        } else {
            $this->error .= 'AUTH CRAM-MD5 rejected: '.trim($response).$this->LF;
            return false;
        }
    }

    /**
     * Implementation of SASL mechanism CRAM-SHA1
     *
     * @param    string    Username
     * @param    string    Password
     * @return   boolean   TRUE on successful authentication, FALSE otherwise
     * @access   private
     */
    private function _auth_cram_sha1($user = '', $pass = '')
    {
        $response = $this->talk('AUTH CRAM-SHA1');
        if (substr($response, 0, 3) == '334') {
            // Get the challenge from the server
            $challenge = base64_decode(substr(trim($response), 4));
            // Secret to use
            $secret = $pass;
            // Rightpad with NUL bytes to have 64 chars
            if (strlen($secret) < 64) $secret = $secret.str_repeat(chr(0x00), 64 - strlen($secret));
            // In case, the secret is longer than 64 chars, sha1() it
            if (strlen($secret) > 64) $secret = sha1($secret);

            $ipad = str_repeat(chr(0x36), 64);
            $opad = str_repeat(chr(0x5c), 64);
            $shared = bin2hex(pack('H*', sha1(($secret ^ $opad).pack('H*', sha1(($secret ^ $ipad).$challenge)))));

            $response = $this->talk(base64_encode($user.' '.$shared));
            if (substr($response, 0, 3) != '334' && substr($response, 0, 3) != '235') {
                $this->error .= 'AUTH CRAM-SHA1 failed:'.trim($response).$this->LF;
                return false;
            }
            return true;
        } else {
            $this->error .= 'AUTH CRAM-SHA1 rejected: '.trim($response).$this->LF;
            return false;
        }
    }

    /**
     * Implementation of SASL mechanism CRAM-SHA256
     *
     * @param    string    Username
     * @param    string    Password
     * @return   boolean   TRUE on successful authentication, FALSE otherwise
     * @access   private
     */
    private function _auth_cram_sha256($user = '', $pass = '')
    {
        // See RFC2104 (HMAC, also known as Keyed-MD5)
        $response = $this->talk('AUTH CRAM-SHA256');
        if (substr($response, 0, 3) == '334') {
            // Get the challenge from the server
            $challenge = base64_decode(substr(trim($response), 4));
            // Secret to use
            $secret = $pass;
            // Rightpad with NUL bytes to have 64 chars
            if (strlen($secret) < 64) $secret = $secret.str_repeat(chr(0x00), 64 - strlen($secret));
            // In case, the secret is longer than 64 chars, md5() it
            if (strlen($secret) > 64) $secret = sha256($secret);

            $ipad = str_repeat(chr(0x36), 64);
            $opad = str_repeat(chr(0x5c), 64);
            $shared = bin2hex(pack('H*', sha256(($secret ^ $opad).pack('H*', sha256(($secret ^ $ipad).$challenge)))));

            $response = $this->talk(base64_encode($user.' '.$shared));
            if (substr($response, 0, 3) != '334' && substr($response, 0, 3) != '235') {
                $this->error .= 'AUTH CRAM-SHA256 failed:'.trim($response).$this->LF;
                return false;
            }
            return true;
        } else {
            $this->error .= 'AUTH CRAM-SHA256 rejected: '.trim($response).$this->LF;
            return false;
        }
    }

    /**
     * Implementation of SASL mechanism LOGIN
     *
     * @param    string    Username
     * @param    string    Password
     * @return   boolean   TRUE on successful authentication, FALSE otherwise
     * @access   private
     */
    private function _auth_login($user = '', $pass = '')
    {
        $response = $this->talk('AUTH LOGIN');
        if (substr($response, 0, 3) == '334') {
            $response = $this->talk(base64_encode($user));
            if (substr($response, 0, 3) != '334') {
                $this->error .= 'AUTH LOGIN failed, wrong username? Aborting authentication.'.$this->LF;
                return false;
            }
            $response = $this->talk(base64_encode($pass));
            if (substr($response, 0, 3) != '235') {
                $this->error .= 'AUTH LOGIN failed, wrong password? Aborting authentication.'.$this->LF;
                return false;
            }
            return true;
        } else {
            $this->error .= 'AUTH LOGIN rejected: '.trim($response).$this->LF;
            return false;
        }
    }

    /**
     * Implementation of SASL mechanism PLAIN
     *
     * @param    string    Username
     * @param    string    Password
     * @return   boolean   TRUE on successful authentication, FALSE otherwise
     * @access   private
     */
    private function _auth_plain($user = '', $pass = '')
    {
        $response = $this->talk('AUTH PLAIN '.base64_encode(chr(0).$user.chr(0).$pass));
        if (substr($response, 0, 3) != '235') {
            $this->error .= 'AUTH PLAIN failed. Aborting authentication.'.$this->LF;
            return false;
        }
        return true;
    }
}
?>
Return current item: phlyMail Lite