PageRenderTime 42ms CodeModel.GetById 15ms RepoModel.GetById 0ms app.codeStats 0ms

/base/lib/flourishlib/fSMTP.php

https://bitbucket.org/thanhtungnguyenphp/monitos
PHP | 625 lines | 331 code | 81 blank | 213 comment | 66 complexity | da51aef7d9d44bc8832acc0b992fbace MD5 | raw file
  1. <?php
  2. /**
  3. * Creates a connection to an SMTP server to be used by fEmail
  4. *
  5. * @copyright Copyright (c) 2010-2011 Will Bond
  6. * @author Will Bond [wb] <will@flourishlib.com>
  7. * @license http://flourishlib.com/license
  8. *
  9. * @package Flourish
  10. * @link http://flourishlib.com/fSMTP
  11. *
  12. * @version 1.0.0b10
  13. * @changes 1.0.0b10 Added code to work around PHP bug #42682 (http://bugs.php.net/bug.php?id=42682) where `stream_select()` doesn't work on 64bit machines from PHP 5.2.0 to 5.2.5, improved timeouts while reading data [wb, 2011-01-10]
  14. * @changes 1.0.0b9 Fixed a bug where lines starting with `.` and containing other content would have the `.` stripped [wb, 2010-09-11]
  15. * @changes 1.0.0b8 Updated the class to use fEmail::getFQDN() [wb, 2010-09-07]
  16. * @changes 1.0.0b7 Updated class to use new fCore::startErrorCapture() functionality [wb, 2010-08-09]
  17. * @changes 1.0.0b6 Updated the class to use new fCore functionality [wb, 2010-07-05]
  18. * @changes 1.0.0b5 Hacked around a bug in PHP 5.3 on Windows [wb, 2010-06-22]
  19. * @changes 1.0.0b4 Updated the class to not connect and authenticate until a message is sent, moved message id generation in fEmail [wb, 2010-05-05]
  20. * @changes 1.0.0b3 Fixed a bug with connecting to servers that send an initial response of `220-` and instead of `220 ` [wb, 2010-04-26]
  21. * @changes 1.0.0b2 Fixed a bug where `STARTTLS` would not be triggered if it was last in the SMTP server's list of supported extensions [wb, 2010-04-20]
  22. * @changes 1.0.0b The initial implementation [wb, 2010-04-20]
  23. */
  24. class fSMTP
  25. {
  26. /**
  27. * The authorization methods that are valid for this server
  28. *
  29. * @var array
  30. */
  31. private $auth_methods;
  32. /**
  33. * The socket connection to the SMTP server
  34. *
  35. * @var resource
  36. */
  37. private $connection;
  38. /**
  39. * If debugging has been enabled
  40. *
  41. * @var boolean
  42. */
  43. private $debug;
  44. /**
  45. * The hostname or IP of the SMTP server
  46. *
  47. * @var string
  48. */
  49. private $host;
  50. /**
  51. * The maximum size message the SMTP server supports
  52. *
  53. * @var integer
  54. */
  55. private $max_size;
  56. /**
  57. * The password to authenticate with
  58. *
  59. * @var string
  60. */
  61. private $password;
  62. /**
  63. * If the server supports pipelining
  64. *
  65. * @var boolean
  66. */
  67. private $pipelining;
  68. /**
  69. * The port the SMTP server is on
  70. *
  71. * @var integer
  72. */
  73. private $port;
  74. /**
  75. * If the connection to the SMTP server is secure
  76. *
  77. * @var boolean
  78. */
  79. private $secure;
  80. /**
  81. * The timeout for the connection
  82. *
  83. * @var integer
  84. */
  85. private $timeout;
  86. /**
  87. * The username to authenticate with
  88. *
  89. * @var string
  90. */
  91. private $username;
  92. /**
  93. * Configures the SMTP connection
  94. *
  95. * The SMTP connection is only made once authentication is attempted or
  96. * an email is sent.
  97. *
  98. * Please note that this class will upgrade the connection to TLS via the
  99. * SMTP `STARTTLS` command if possible, even if a secure connection was not
  100. * requested. This helps to keep authentication information secure.
  101. *
  102. * @param string $host The hostname or IP address to connect to
  103. * @param integer $port The port number to use
  104. * @param boolean $secure If the connection should be secure - if `STARTTLS` is available, the connection will be upgraded even if this is `FALSE`
  105. * @param integer $timeout The timeout for the connection - defaults to the `default_socket_timeout` ini setting
  106. * @return fSMTP
  107. */
  108. public function __construct($host, $port=NULL, $secure=FALSE, $timeout=NULL)
  109. {
  110. if ($timeout === NULL) {
  111. $timeout = ini_get('default_socket_timeout');
  112. }
  113. if ($port === NULL) {
  114. $port = !$secure ? 25 : 465;
  115. }
  116. if ($secure && !extension_loaded('openssl')) {
  117. throw new fEnvironmentException(
  118. 'A secure connection was requested, but the %s extension is not installed',
  119. 'openssl'
  120. );
  121. }
  122. $this->host = $host;
  123. $this->port = $port;
  124. $this->secure = $secure;
  125. $this->timeout = $timeout;
  126. }
  127. /**
  128. * Closes the connection to the SMTP server
  129. *
  130. * @return void
  131. */
  132. public function __destruct()
  133. {
  134. $this->close();
  135. }
  136. /**
  137. * All requests that hit this method should be requests for callbacks
  138. *
  139. * @internal
  140. *
  141. * @param string $method The method to create a callback for
  142. * @return callback The callback for the method requested
  143. */
  144. public function __get($method)
  145. {
  146. return array($this, $method);
  147. }
  148. /**
  149. * Authenticates with the SMTP server
  150. *
  151. * This method supports the digest-md5, cram-md5, login and plain
  152. * SMTP authentication methods. This method will try to use the more secure
  153. * digest-md5 and cram-md5 methods first since they do not send information
  154. * in the clear.
  155. *
  156. * @throws fValidationException When the `$username` and `$password` are not accepted
  157. *
  158. * @param string $username The username
  159. * @param string $password The password
  160. * @return void
  161. */
  162. public function authenticate($username, $password)
  163. {
  164. $this->username = $username;
  165. $this->password = $password;
  166. }
  167. /**
  168. * Closes the connection to the SMTP server
  169. *
  170. * @return void
  171. */
  172. public function close()
  173. {
  174. if (!$this->connection) {
  175. return;
  176. }
  177. $this->write('QUIT', 1);
  178. fclose($this->connection);
  179. $this->connection = NULL;
  180. }
  181. /**
  182. * Initiates the connection to the server
  183. *
  184. * @return void
  185. */
  186. private function connect()
  187. {
  188. if ($this->connection) {
  189. return;
  190. }
  191. $fqdn = fEmail::getFQDN();
  192. fCore::startErrorCapture(E_WARNING);
  193. $host = ($this->secure) ? 'tls://' . $this->host : $this->host;
  194. $this->connection = fsockopen($host, $this->port, $error_int, $error_string, $this->timeout);
  195. foreach (fCore::stopErrorCapture('#ssl#i') as $error) {
  196. throw new fConnectivityException('There was an error connecting to the server. A secure connection was requested, but was not available. Try a non-secure connection instead.');
  197. }
  198. if (!$this->connection) {
  199. throw new fConnectivityException('There was an error connecting to the server');
  200. }
  201. stream_set_timeout($this->connection, $this->timeout);
  202. $response = $this->read('#^220 #');
  203. if (!$this->find($response, '#^220[ -]#')) {
  204. throw new fConnectivityException(
  205. 'Unknown SMTP welcome message, %1$s, from server %2$s on port %3$s',
  206. join("\r\n", $response),
  207. $this->host,
  208. $this->port
  209. );
  210. }
  211. // Try sending the ESMTP EHLO command, but fall back to normal SMTP HELO
  212. $response = $this->write('EHLO ' . $fqdn, '#^250 #m');
  213. if ($this->find($response, '#^500#')) {
  214. $response = $this->write('HELO ' . $fqdn, 1);
  215. }
  216. // If STARTTLS is available, use it
  217. if (!$this->secure && extension_loaded('openssl') && $this->find($response, '#^250[ -]STARTTLS#')) {
  218. $response = $this->write('STARTTLS', '#^220 #');
  219. $affirmative = $this->find($response, '#^220[ -]#');
  220. if ($affirmative) {
  221. do {
  222. if (isset($res)) {
  223. sleep(0.1);
  224. }
  225. $res = stream_socket_enable_crypto($this->connection, TRUE, STREAM_CRYPTO_METHOD_TLS_CLIENT);
  226. } while ($res === 0);
  227. }
  228. if (!$affirmative || $res === FALSE) {
  229. throw new fConnectivityException('Error establishing secure connection');
  230. }
  231. $response = $this->write('EHLO ' . $fqdn, '#^250 #m');
  232. }
  233. $this->max_size = 0;
  234. if ($match = $this->find($response, '#^250[ -]SIZE\s+(\d+)$#')) {
  235. $this->max_size = $match[0][1];
  236. }
  237. $this->pipelining = (boolean) $this->find($response, '#^250[ -]PIPELINING$#');
  238. $auth_methods = array();
  239. if ($match = $this->find($response, '#^250[ -]AUTH[ =](.*)$#')) {
  240. $auth_methods = array_map('strtoupper', explode(' ', $match[0][1]));
  241. }
  242. if (!$auth_methods || !$this->username) {
  243. return;
  244. }
  245. if (in_array('DIGEST-MD5', $auth_methods)) {
  246. $response = $this->write('AUTH DIGEST-MD5', 1);
  247. $this->handleErrors($response);
  248. $match = $this->find($response, '#^334 (.*)$#');
  249. $challenge = base64_decode($match[0][1]);
  250. preg_match_all('#(?<=,|^)(\w+)=("[^"]+"|[^,]+)(?=,|$)#', $challenge, $matches, PREG_SET_ORDER);
  251. $request_params = array();
  252. foreach ($matches as $_match) {
  253. $request_params[$_match[1]] = ($_match[2][0] == '"') ? substr($_match[2], 1, -1) : $_match[2];
  254. }
  255. $missing_qop_auth = !isset($request_params['qop']) || !in_array('auth', explode(',', $request_params['qop']));
  256. $missing_nonce = empty($request_params['nonce']);
  257. if ($missing_qop_auth || $missing_nonce) {
  258. throw new fUnexpectedException(
  259. 'The SMTP server %1$s on port %2$s claims to support DIGEST-MD5, but does not seem to provide auth functionality',
  260. $this->host,
  261. $this->port
  262. );
  263. }
  264. if (!isset($request_params['realm'])) {
  265. $request_params['realm'] = '';
  266. }
  267. // Algorithm from http://www.ietf.org/rfc/rfc2831.txt
  268. $realm = $request_params['realm'];
  269. $nonce = $request_params['nonce'];
  270. $cnonce = fCryptography::randomString('32', 'hexadecimal');
  271. $nc = '00000001';
  272. $digest_uri = 'smtp/' . $this->host;
  273. $a1 = md5($this->username . ':' . $realm . ':' . $this->password, TRUE) . ':' . $nonce . ':' . $cnonce;
  274. $a2 = 'AUTHENTICATE:' . $digest_uri;
  275. $response = md5(md5($a1) . ':' . $nonce . ':' . $nc . ':' . $cnonce . ':auth:' . md5($a2));
  276. $response_params = array(
  277. 'charset=utf-8',
  278. 'username="' . $this->username . '"',
  279. 'realm="' . $realm . '"',
  280. 'nonce="' . $nonce . '"',
  281. 'nc=' . $nc,
  282. 'cnonce="' . $cnonce . '"',
  283. 'digest-uri="' . $digest_uri . '"',
  284. 'response=' . $response,
  285. 'qop=auth'
  286. );
  287. $response = $this->write(base64_encode(join(',', $response_params)), 2);
  288. } elseif (in_array('CRAM-MD5', $auth_methods)) {
  289. $response = $this->write('AUTH CRAM-MD5', 1);
  290. $match = $this->find($response, '#^334 (.*)$#');
  291. $challenge = base64_decode($match[0][1]);
  292. $response = $this->write(base64_encode($this->username . ' ' . fCryptography::hashHMAC('md5', $challenge, $this->password)), 1);
  293. } elseif (in_array('LOGIN', $auth_methods)) {
  294. $response = $this->write('AUTH LOGIN', 1);
  295. $this->write(base64_encode($this->username), 1);
  296. $response = $this->write(base64_encode($this->password), 1);
  297. } elseif (in_array('PLAIN', $auth_methods)) {
  298. $response = $this->write('AUTH PLAIN ' . base64_encode($this->username . "\0" . $this->username . "\0" . $this->password), 1);
  299. }
  300. if ($this->find($response, '#^535[ -]#')) {
  301. throw new fValidationException(
  302. 'The username and password provided were not accepted for the SMTP server %1$s on port %2$s',
  303. $this->host,
  304. $this->port
  305. );
  306. }
  307. if (!array_filter($response)) {
  308. throw new fConnectivityException('No response was received for the authorization request');
  309. }
  310. }
  311. /**
  312. * Sets if debug messages should be shown
  313. *
  314. * @param boolean $flag If debugging messages should be shown
  315. * @return void
  316. */
  317. public function enableDebugging($flag)
  318. {
  319. $this->debug = (boolean) $flag;
  320. }
  321. /**
  322. * Searches the response array for the the regex and returns any matches
  323. *
  324. * @param array $response The lines of data to search through
  325. * @param string $regex The regex to search with
  326. * @return array The regex matches
  327. */
  328. private function find($response, $regex)
  329. {
  330. $matches = array();
  331. foreach ($response as $line) {
  332. if (preg_match($regex, $line, $match)) {
  333. $matches[] = $match;
  334. }
  335. }
  336. return $matches;
  337. }
  338. /**
  339. * Searches the response array for SMTP error codes
  340. *
  341. * @param array $response The response array to search through
  342. * @return void
  343. */
  344. private function handleErrors($response)
  345. {
  346. $codes = array(
  347. 450, 451, 452, 500, 501, 502, 503, 504, 521, 530, 550, 551, 552, 553
  348. );
  349. $regex = '#^(' . join('|', $codes) . ')#';
  350. $errors = array();
  351. foreach ($response as $line) {
  352. if (preg_match($regex, $line)) {
  353. $errors[] = $line;
  354. }
  355. }
  356. if ($errors) {
  357. throw new fUnexpectedException(
  358. "The following unexpected SMTP errors occurred for the server %1\$s on port %2\$s:\n%3\$s",
  359. $this->host,
  360. $this->port,
  361. join("\n", $errors)
  362. );
  363. }
  364. }
  365. /**
  366. * Reads lines from the SMTP server
  367. *
  368. * @param integer|string $expect The expected number of lines of response or a regex of the last line
  369. * @return array The lines of response from the server
  370. */
  371. private function read($expect)
  372. {
  373. $response = array();
  374. if ($result = $this->select($this->timeout, 0)) {
  375. while (!feof($this->connection)) {
  376. $line = fgets($this->connection);
  377. if ($line === FALSE) {
  378. break;
  379. }
  380. $line = substr($line, 0, -2);
  381. if (is_string($result)) {
  382. $line = $result . $line;
  383. }
  384. $response[] = $line;
  385. if ($expect !== NULL) {
  386. $result = NULL;
  387. $matched_number = is_int($expect) && sizeof($response) == $expect;
  388. $matched_regex = is_string($expect) && preg_match($expect, $response[sizeof($response)-1]);
  389. if ($matched_number || $matched_regex) {
  390. break;
  391. }
  392. } elseif (!($result = $this->select(0, 200000))) {
  393. break;
  394. }
  395. }
  396. }
  397. if (fCore::getDebug($this->debug)) {
  398. fCore::debug("Received:\n" . join("\r\n", $response), $this->debug);
  399. }
  400. $this->handleErrors($response);
  401. return $response;
  402. }
  403. /**
  404. * Performs a "fixed" stream_select() for the connection
  405. *
  406. * @param integer $timeout The number of seconds in the timeout
  407. * @param integer $utimeout The number of microseconds in the timeout
  408. * @return boolean|string TRUE (or a character) is the connection is ready to be read from, FALSE if not
  409. */
  410. private function select($timeout, $utimeout)
  411. {
  412. $read = array($this->connection);
  413. $write = NULL;
  414. $except = NULL;
  415. // PHP 5.2.0 to 5.2.5 had a bug on amd64 linux where stream_select()
  416. // fails, so we have to fake it - http://bugs.php.net/bug.php?id=42682
  417. static $broken_select = NULL;
  418. if ($broken_select === NULL) {
  419. $broken_select = strpos(php_uname('m'), '64') !== FALSE && fCore::checkVersion('5.2.0') && !fCore::checkVersion('5.2.6');
  420. }
  421. // Fixes an issue with stream_select throwing a warning on PHP 5.3 on Windows
  422. if (fCore::checkOS('windows') && fCore::checkVersion('5.3.0')) {
  423. $select = @stream_select($read, $write, $except, $timeout, $utimeout);
  424. } elseif ($broken_select) {
  425. $broken_select_buffer = NULL;
  426. $start_time = microtime(TRUE);
  427. $i = 0;
  428. do {
  429. if ($i) {
  430. usleep(50000);
  431. }
  432. $char = fgetc($this->connection);
  433. if ($char != "\x00" && $char !== FALSE) {
  434. $broken_select_buffer = $char;
  435. }
  436. $i++;
  437. if ($i > 2) {
  438. break;
  439. }
  440. } while ($broken_select_buffer === NULL && microtime(TRUE) - $start_time < ($timeout + ($utimeout/1000000)));
  441. $select = $broken_select_buffer === NULL ? FALSE : $broken_select_buffer;
  442. } else {
  443. $select = stream_select($read, $write, $except, $timeout, $utimeout);
  444. }
  445. return $select;
  446. }
  447. /**
  448. * Sends a message via the SMTP server
  449. *
  450. * @internal
  451. *
  452. * @throws fValidationException When the message is too large for the server
  453. *
  454. * @param string $from The email address being sent from - this will be used as the `Return-Path` header
  455. * @param array $to All of the To, Cc and Bcc email addresses to send the message to - this does not affect the message headers in any way
  456. * @param string $headers The message headers - the Bcc header will be removed if present
  457. * @param string $body The mail body
  458. * @return void
  459. */
  460. public function send($from, $to, $headers, $body)
  461. {
  462. $this->connect();
  463. // Lines starting with . need to start with two .s because the leading
  464. // . will be stripped
  465. $body = preg_replace('#^\.#m', '..', $body);
  466. // Removed the Bcc header incase the SMTP server doesn't
  467. $headers = preg_replace('#^Bcc:(.*?)\r\n([^ ])#mi', '\2', $headers);
  468. // Add the Date header
  469. $headers = "Date: " . date('D, j M Y H:i:s O') . "\r\n" . $headers;
  470. $data = $headers . "\r\n\r\n" . $body;
  471. if ($this->max_size && strlen($data) > $this->max_size) {
  472. throw new fValidationException(
  473. 'The email provided is %1$s, which is larger than the maximum size of %2$s that the server supports',
  474. fFilesystem::formatFilesize(strlen($data)),
  475. fFilesystem::formatFilesize($this->max_size)
  476. );
  477. }
  478. $mail_from = "MAIL FROM:<" . $from . ">";
  479. if ($this->pipelining) {
  480. $expect = 2;
  481. $rcpt_to = '';
  482. foreach ($to as $email) {
  483. $rcpt_to .= "RCPT TO:<" . $email . ">\r\n";
  484. $expect++;
  485. }
  486. $rcpt_to = trim($rcpt_to);
  487. $this->write($mail_from . "\r\n" . $rcpt_to . "\r\nDATA\r\n", $expect);
  488. } else {
  489. $this->write($mail_from, 1);
  490. foreach ($to as $email) {
  491. $this->write("RCPT TO:<" . $email . ">", 1);
  492. }
  493. $this->write('DATA', 1);
  494. }
  495. $this->write($data . "\r\n.\r\n", 1);
  496. $this->write('RSET', 1);
  497. }
  498. /**
  499. * Sends raw text/commands to the SMTP server
  500. *
  501. * @param string $data The data or commands to send
  502. * @param integer|string $expect The expected number of lines of response or a regex of the last line
  503. * @return array The response from the server
  504. */
  505. private function write($data, $expect)
  506. {
  507. if (!$this->connection) {
  508. throw new fProgrammerException('Unable to send data since the connection has already been closed');
  509. }
  510. if (substr($data, -2) != "\r\n") {
  511. $data .= "\r\n";
  512. }
  513. if (fCore::getDebug($this->debug)) {
  514. fCore::debug("Sending:\n" . trim($data), $this->debug);
  515. }
  516. $res = fwrite($this->connection, $data);
  517. if ($res === FALSE) {
  518. throw new fConnectivityException('Unable to write data to SMTP server %1$s on port %2$s', $this->host, $this->port);
  519. }
  520. $response = $this->read($expect);
  521. return $response;
  522. }
  523. }
  524. /**
  525. * Copyright (c) 2010-2011 Will Bond <will@flourishlib.com>
  526. *
  527. * Permission is hereby granted, free of charge, to any person obtaining a copy
  528. * of this software and associated documentation files (the "Software"), to deal
  529. * in the Software without restriction, including without limitation the rights
  530. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  531. * copies of the Software, and to permit persons to whom the Software is
  532. * furnished to do so, subject to the following conditions:
  533. *
  534. * The above copyright notice and this permission notice shall be included in
  535. * all copies or substantial portions of the Software.
  536. *
  537. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  538. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  539. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  540. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  541. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  542. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  543. * THE SOFTWARE.
  544. */