<?php
/**
* POP3 client connector class with transparent SSL support
*
* @package phlyMail Nahariya 4.0+
* @subpackage Email handler
* @subpackage Network client connectivity
* @author Matthias Sommerfeld <hide@address.com>
* @copyright 2005-2010 phlyLabs, Berlin (http://phlylabs.de)
* @version 3.7.2 2010-05-11
*/
class pop3 {
public $append_errors = true;
public $timestamp_errors = false;
private $error = false;
protected $has_tls = false;
protected $may_tls = true;
protected $is_tls = false;
protected $is_ssl = false;
protected $_diag_session = false;
// List of SASL mechanisms we support
protected $SASL = array( 'cram_sha256', 'cram_sha1', 'cram_md5', 'login', 'plain');
public function __construct($server, $port = 110, $recon_slp = 0, $allowtls = true)
{
if ($this->_diag_session) $this->diag = fopen(dirname(__FILE__).'/pop3_diag.txt', 'a');
$this->may_tls = $allowtls;
if (!$port) $port = 110;
$this->connected = false;
if ($this->connect($server, $port)) {
$this->server = $server;
$this->port = $port;
$this->reconnect_sleep = isset($recon_slp) ? $recon_slp : 0;
$this->connected = true;
}
return true;
}
/**
* Sole aim is, to know, whether we are connected or not
* since we cannot return something useful on construction
* of the object
*/
public function check_connected()
{
return $this->connected;
}
public function get_last_error()
{
$return = ($this->error) ? $this->error : '';
$this->error = false;
return $return;
}
/**
* Log in to POP3 server
* @param string $username
* @param string $password
* @param int $apop Set to 1 to allow APOP, set to 0 to disallow even if advertised by server
*/
public function login($username = '', $password = '', $apop = 1)
{
$return = array('type' => false, 'login' => false);
// Issue empty command - some POP3 server constantly drop the first command sent to them
// This is somewhat violating RFC1939 but who is mistaking here?
// Mainly to blame is qmail at this point ...
if (!$this->is_ssl) $this->noop();
// Try to find out about POP3 AUTH and use it on success
$capa = $this->capa(false);
if ($this->has_tls && false !== $capa && isset($capa['stls']) && $capa['stls']) {
$this->talk('STLS');
$res = stream_socket_enable_crypto($this->fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
if (!$res) {
$this->close();
$this->set_error('Cannot enable TLS, although server advertises it');
} else {
$this->is_tls = true;
$return['type'] = 'secure';
}
// Now check, whether the connections has not been closed
if (!$this->alive()) {
$this->set_error($response);
$this->close();
sleep($this->reconnect_sleep);
$this->connect($this->server, $this->port);
if (!$this->alive()) return $return;
if (!$this->is_ssl) $this->noop();
}
}
// Server supports POP3 AUTH... try the supported mechanisms to authenticate
if (!$this->is_ssl && !$this->is_tls && false !== $capa && $capa['sasl'] != false) {
// Find the mechanisms supported on both sides
$SASL = array_intersect($this->SASL, $capa['sasl_internal']);
$this->is_auth = false;
foreach ($SASL as $v) {
$function_name = '_auth_'.$v;
if ($this->{$function_name}($username, $password)) {
$return['login'] = 1;
$return['type'] = 'secure';
return $return;
}
}
// Now check, whether the connections has not been closed
if (!$this->alive()) {
$this->set_error($response);
$this->close();
sleep($this->reconnect_sleep);
$this->connect($this->server, $this->port);
if (!$this->alive()) return $return;
if (!$this->is_ssl) $this->noop();
}
}
// APOP
if (!$this->is_ssl && !$this->is_tls && 1 != $return['login']
&& preg_match('/(<.+@.+>)$/', $this->greeting, $token) && $apop == 1) {
$response = $this->talk('APOP '.$username.' '.md5($token[1].$password));
if (strtolower(substr($response, 0, 3)) == '+ok') {
$return['login'] = 1;
$return['type'] = 'secure';
return $return;
}
// APOP failed due to bogus server advertising
// Now check, whether the connections has not been closed
if (!$this->alive()) {
$this->set_error($response);
$this->close();
sleep($this->reconnect_sleep);
$this->connect($this->server, $this->port);
if (!$this->alive()) return $return;
if (!$this->is_ssl) $this->noop();
}
}
// USER/PASS
if (1 != $return['login']) {
$response = $this->talk('USER '.$username);
if (strtolower(substr($response, 0, 4)) == '-err') {
$this->set_error($response);
if (!$this->alive()) return $return;
}
$response = $this->talk('PASS '.$password);
if (strtolower(substr($response, 0, 3)) == '+ok') {
$return['login'] = 1;
$return['type'] = 'normal';
} else {
$this->set_error($response);
if (!$this->alive()) return $return;
}
}
return $return;
}
/**
* Allows to specifically query the server for a capabilites list. As defined
* in RFC2449, only two server reactions are possible:
* +OK <Multiline capabilites reponse> and -ERR, where the latter means, that
* this server does not support this command. The multiline response can
* reveal some useful information about the type of mailserver, the retention
* policy and supported SASL mechanisms, if any.
*
* @param bool Set to true to receive the unparsed response, Default: false,
* which will give you a nice little array of recognized capabilities
* @return mixed String on $raw = true, array of recognized features otherwise
* @since 3.6.0
*/
public function capa($raw = false)
{
$return = array('top' => false, 'user' => false, 'uidl' => false, 'stls' => false, 'sasl' => false, 'login-delay' => 0
,'expire' => 'never', 'implementation' => 'unknown', 'resp-codes' => false, 'pipelining' => false);
$response = $this->talk('CAPA');
if ('+ok' == strtolower(substr($response, 0, 3))) {
if ($raw) {
$return = $response;
while ($line = $this->talk_ml()) {
$return .= $line;
}
return $return;
}
while ($line = $this->talk_ml()) {
$capa = explode(' ', trim($line), 2);
$capa[0] = strtolower($capa[0]);
switch ($capa[0]) {
case 'top':
case 'user':
case 'uidl':
case 'stls':
case 'resp-codes':
case 'pipelining':
$return[$capa[0]] = true;
break;
case 'implementation':
case 'login-delay':
case 'expire':
$return[$capa[0]] = $capa[1];
break;
case 'sasl':
$return[$capa[0]] = explode(' ', $capa[1]);
$return['sasl_internal'] = explode(' ', strtolower(str_replace('-', '_', $capa[1])));
break;
}
}
return $return;
} else {
$response = trim($response);
if (!$response) return $return;
$this->set_error('POP server response: '.$response);
return false;
}
}
/**
* Return LIST, if mail given of this one, else complete
*
*[@param int $mail Number of the mail in the list to get info about]
* @return array|false Array data on succes, false on failure
*/
public function get_list($mail = false)
{
if ($mail) {
$line = explode(' ', $this->talk('LIST '.$mail));
if ('+ok' == strtolower($line[0])) {
return array
('size' => $line[2], 'recent' => true
,'flagged' => false, 'answered' => false
,'seen' => false, 'draft' => false
);
} else {
$this->set_error('POP server response: '.implode(' ', $line));
return false;
}
} else {
$line = explode(' ', $this->talk('LIST'));
if ('+ok' == strtolower($line[0])) {
$return = array();
while ($line = $this->talk_ml()) {
list($nummer, $bytes) = explode(' ', trim($line), 2);
$return[$nummer] = array
('size' => $bytes, 'recent' => true
,'flagged' => false, 'answered' => false
,'seen' => false, 'draft' => false
);
}
foreach ($return as $num => $flags) {
$return[$num]['uidl'] = $this->uidl($num);
}
return $return;
} else {
$this->set_error('POP server response: '.implode(' ', $line));
return false;
}
}
}
// Get the header lines of a mail
public function top($mail)
{
$return = '';
$response = explode(' ', $this->talk('TOP '.$mail.' 0'));
if ('+ok' == strtolower($response[0])) {
while ($line = $this->talk_ml()) $return .= $line;
return $return;
} else {
$this->set_error('POP server response: '.implode(' ', $response));
return false;
}
}
// Get the Unique ID of a mail
public function uidl($mail)
{
$response = explode(' ', $this->talk('UIDL '.$mail));
if ('+ok' == strtolower($response[0])) {
return $response[2];
} else {
$this->set_error('POP server response: '.implode(' ', $response));
return false;
}
}
// Get stats of a POP3 box
public function stat()
{
$return = array('mails' => false, 'size' => false);
$response = explode(' ', $this->talk('STAT'));
if ('+ok' == strtolower($response[0])) {
return array('mails' => $response[1], 'size' => $response[2]);
} else {
$this->set_error('POP server response: '.implode(' ', $response));
return false;
}
}
// Delete a selected Email from POP3 server
public function delete($mail)
{
$response = explode(' ', $this->talk('DELE '.$mail));
if ('+ok' == strtolower($response[0])) {
return true;
} else {
$this->set_error('POP server response: '.implode(' ', $response));
return false;
}
}
/**
* Just an alias of delete()
*
* @param int $mail Number of the mail
* @return bool
* @see delete()
*/
public function removeMessage($mail)
{
return $this->delete($mail);
}
/**
* Takes a list of UIDLs, which are to delete from the server's mailbox
* @param array values are the UIDLs
* @return mixed TRUE on success, FALSE on failures, array of unknown UIDLs
* @since 3.1.9
*/
public function delete_by_uidl($uidls)
{
if (!$uidls || empty($uidls)) return true;
$response = explode(' ', $this->talk('UIDL'));
if ('-err' == strtolower($response[0])) return $uidls;
$check = array();
while ($line = $this->talk_ml()) {
$serv = explode(' ', trim($line));
$check[$serv[0]] = $serv[1];
}
if (empty($check)) return $uidls;
$return = array();
foreach ($uidls as $uidl) {
$hit = array_search($uidl, $check);
if ($hit) {
$this->delete($hit);
} else {
$return[] = $uidl;
}
}
return (empty($return)) ? true : $return;
}
// Do nothing.
// Since RFC1939 requires a positive response, we don't care about errors yet
public function noop()
{
$this->talk('NOOP');
return true;
}
// Unmark any mails marked as deleted.
// Since RFC1939 requires a positive response, we don't care about errors yet
public function reset()
{
$this->talk('RSET');
return true;
}
// Send RETR command to POP3 server
// Get subsequent server responses via talk_ml()
public function retrieve($mail)
{
$response = explode(' ', $this->talk('RETR '.$mail));
if ('+ok' == strtolower($response[0])) {
return true;
} else {
$this->set_error('POP server response: '.implode(' ', $response));
return false;
}
}
// Retrieve a mail from server and put into given file
public function retrieve_to_file($mail = false, $path = false)
{
$old_umask = umask(0);
if (!$mail || !$path) {
$this->set_error('Usage: retrieve_to_file(integer mail, string path)');
return false;
}
if (!file_exists(dirname($path)) || !is_dir(dirname($path))) {
$this->set_error('Non existent directory '.dirname($path));
return false;
}
$out = fopen($path, 'w');
if (!$out) {
$this->set_error('Could not open file '.$path);
return false;
}
$response = explode(' ', $this->talk('RETR '.$mail));
if ('+ok' == strtolower($response[0])) {
while (true) {
$line = $this->talk_ml();
if (false === $line) break;
fputs($out, $line);
}
fclose($out);
chmod($path, $GLOBALS['_PM_']['core']['file_umask']);
umask($old_umask);
return $path;
} else {
$this->set_error('POP server response: '.implode(' ', $response));
return false;
}
}
// Send command to POP3 server and return first line of response
public function talk($input = false)
{
if (!$input) return false;
if ($this->_diag_session) fputs($this->diag, 'C: '.$input.CRLF);
fputs($this->fp, $input.CRLF);
$line = fgets($this->fp, 4096);
if ($this->_diag_session) fputs($this->diag, 'S: '.$line);
return trim($line);
}
// Return a line of multiline POP3 responses, return false on last line
public function talk_ml()
{
$line = fgets($this->fp, 1024);
if ($this->_diag_session) fputs($this->diag, 'S: '.$line);
if (isset($line{0}) && $line{0} == '.') {
$line = substr($line, 1);
if (CRLF == $line) return false;
return $line;
}
return $line;
}
// Close POP3 connection
public function close()
{
$this->talk('QUIT');
fclose($this->fp);
$this->fp = false;
return true;
}
//
// internal methods
//
// Do the actual connect to the chosen server
protected function connect($server = '', $port = 110)
{
$ERRNO = $ERRSTR = 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 == 995) {
if (!$ssl_capable) {
$port = 110;
} else {
$server = (substr($server, 0, 6) == 'ssl://') ? $server : 'ssl://'.$server;
}
} elseif (substr($server, 0, 6) == 'ssl://') {
if (!$ssl_capable) $server = str_replace('ssl://', '', $server);
}
$this->is_ssl = (substr($server, 0, 6) == 'ssl://');
$fp = @fsockopen($server, $port, $ERRNO, $ERRSTR, 1);
if (!$fp) {
$this->set_error('Connection failed: '.$ERRSTR.' ('.$ERRNO.')');
return false;
}
$this->fp = $fp;
$response = trim(fgets($fp, 1024));
if (strtolower(substr($response, 0, 3)) != '+ok') {
$this->set_error($response ? 'POP3 server response: '.$response : 'Bogus POP3 server behaviour!');
return false;
}
$this->greeting = $response;
return true;
}
// Add or set an (timestamped error), that can be requested via get_last_error()
protected function set_error($error)
{
$vorn = ($this->timestamp_errors) ? time().' ' : '';
if ($this->append_errors) {
$this->error .= $vorn.$error.LF;
} else {
$this->error = $vorn.$error;
}
}
// Try to find out, whether the connection is still alive
protected function alive()
{
// Invalid or non-existent handler
if (!$this->fp || !is_resource($this->fp)) return false;
$response = @socket_get_status($this->fp);
if (!$response || $response['timed_out']) return false;
return true;
}
/**
* Implementation of SASL mechanism CRAM-MD5
*
* @param string Username
* @param string Password
* @return boolean TRUE on successful authentication, FALSE otherwise
* @access private
*/
protected function _auth_cram_md5($user = '', $pass = '')
{
// See RFC2104 (HMAC, also known as Keyed-MD5)
$response = $this->talk('AUTH CRAM-MD5');
if (strtoupper(substr($response, 0, 2)) == '+ ') {
// Get the challenge from the server
$challenge = base64_decode(substr(trim($response), 2));
// 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 (strtoupper(substr($response, 0, 3)) != '+OK') {
$this->error .= 'AUTH CRAM-MD5 failed: '.trim($response).LF;
return false;
}
return true;
} else {
$this->error .= 'AUTH CRAM-MD5 rejected: '.trim($response).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
*/
protected function _auth_cram_sha1($user = '', $pass = '')
{
$response = $this->talk('AUTH CRAM-SHA1');
if (strtoupper(substr($response, 0, 2)) == '+ ') {
// Get the challenge from the server
$challenge = base64_decode(substr(trim($response), 2));
// 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 = 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 (strtoupper(substr($response, 0, 3)) != '+OK') {
$this->error .= 'AUTH CRAM-SHA1 failed: '.trim($response).LF;
return false;
}
return true;
} else {
$this->error .= 'AUTH CRAM-SHA1 rejected: '.trim($response).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
*/
protected function _auth_cram_sh2a56($user = '', $pass = '')
{
$response = $this->talk('AUTH CRAM-SHA256');
if (strtoupper(substr($response, 0, 2)) == '+ ') {
// Get the challenge from the server
$challenge = base64_decode(substr(trim($response), 2));
// 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 (strtoupper(substr($response, 0, 3)) != '+OK') {
$this->error .= 'AUTH CRAM-SHA256 failed: '.trim($response).LF;
return false;
}
return true;
} else {
$this->error .= 'AUTH CRAM-SHA256 rejected: '.trim($response).LF;
return false;
}
}
/**
* Implementation of SASL mechanism LOGIN
*
* @param string Username
* @param string Password
* @return boolean TRUE on successful authentication, FALSE otherwise
* @access private
*/
protected function _auth_login($user = '', $pass = '')
{
$response = $this->talk('AUTH LOGIN');
if (substr($response, 0, 2) == '+ ') {
$response = $this->talk(base64_encode($user));
if (substr($response, 0, 1) != '+') {
$this->error .= 'AUTH LOGIN failed, wrong username? Aborting authentication.'.LF;
return false;
}
$response = $this->talk(base64_encode($pass));
if (strtoupper(substr($response, 0, 3)) != '+OK') {
$this->error .= 'AUTH LOGIN failed, wrong password? Aborting authentication.'.LF;
return false;
}
return true;
} else {
$this->error .= 'AUTH LOGIN rejected: '.trim($response).LF;
return false;
}
}
/**
* Implementation of SASL mechanism PLAIN
*
* @param string Username
* @param string Password
* @return boolean TRUE on successful authentication, FALSE otherwise
* @access private
*/
protected function _auth_plain($user = '', $pass = '')
{
$response = $this->talk('AUTH PLAIN '.base64_encode(chr(0).$user.chr(0).$pass));
if (substr($response, 0, 3) != '+OK') {
$this->error .= 'AUTH PLAIN failed: '.$response.LF;
return false;
}
return true;
}
}
?>