PageRenderTime 62ms CodeModel.GetById 31ms RepoModel.GetById 1ms app.codeStats 0ms

/classes/fSMTP.php

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