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

/classes/fSMTP.php

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