PageRenderTime 55ms CodeModel.GetById 18ms RepoModel.GetById 0ms app.codeStats 0ms

/classes/fMailbox.php

https://bitbucket.org/dsqmoore/flourish
PHP | 1497 lines | 920 code | 198 blank | 379 comment | 261 complexity | bb4f15963e071eb62994028367df27fe MD5 | raw file
  1. <?php
  2. /**
  3. * Retrieves and deletes messages from a email account via IMAP or POP3
  4. *
  5. * All headers, text and html content returned by this class are encoded in
  6. * UTF-8. Please see http://flourishlib.com/docs/UTF-8 for more information.
  7. *
  8. * @copyright Copyright (c) 2010-2011 Will Bond
  9. * @author Will Bond [wb] <will@flourishlib.com>
  10. * @license http://flourishlib.com/license
  11. *
  12. * @package Flourish
  13. * @link http://flourishlib.com/fMailbox
  14. *
  15. * @version 1.0.0b14
  16. * @changes 1.0.0b14 Added a workaround for iconv having issues in MAMP 1.9.4+ [wb, 2011-07-26]
  17. * @changes 1.0.0b13 Fixed handling of headers in relation to encoded-words being embedded inside of quoted strings [wb, 2011-07-26]
  18. * @changes 1.0.0b12 Enhanced the error checking in ::write() [wb, 2011-06-03]
  19. * @changes 1.0.0b11 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 connectivity error handling and timeouts while reading data [wb, 2011-01-10]
  20. * @changes 1.0.0b10 Fixed ::parseMessage() to properly handle a header format edge case and properly set the `text` and `html` keys even when the email has an explicit `Content-disposition: inline` header [wb, 2010-11-25]
  21. * @changes 1.0.0b9 Fixed a bug in ::parseMessage() that could cause HTML alternate content to be included in the `inline` content array instead of the `html` element [wb, 2010-09-20]
  22. * @changes 1.0.0b8 Fixed ::parseMessage() to be able to handle non-text/non-html multipart parts that do not have a `Content-disposition` header [wb, 2010-09-18]
  23. * @changes 1.0.0b7 Fixed a typo in ::read() [wb, 2010-09-07]
  24. * @changes 1.0.0b6 Fixed a typo from 1.0.0b4 [wb, 2010-07-21]
  25. * @changes 1.0.0b5 Fixes for increased compatibility with various IMAP and POP3 servers, hacked around a bug in PHP 5.3 on Windows [wb, 2010-06-22]
  26. * @changes 1.0.0b4 Added code to handle emails without an explicit `Content-type` header [wb, 2010-06-04]
  27. * @changes 1.0.0b3 Added missing static method callback constants [wb, 2010-05-11]
  28. * @changes 1.0.0b2 Added the missing ::enableDebugging() [wb, 2010-05-05]
  29. * @changes 1.0.0b The initial implementation [wb, 2010-05-05]
  30. */
  31. class fMailbox
  32. {
  33. const addSMIMEPair = 'fMailbox::addSMIMEPair';
  34. const parseMessage = 'fMailbox::parseMessage';
  35. const reset = 'fMailbox::reset';
  36. /**
  37. * S/MIME certificates and private keys for verification and decryption
  38. *
  39. * @var array
  40. */
  41. static private $smime_pairs = array();
  42. /**
  43. * Adds an S/MIME certificate, or certificate + private key pair for verification and decryption of S/MIME messages
  44. *
  45. * @param string $email_address The email address the certificate or private key is for
  46. * @param fFile|string $certificate_file The file the S/MIME certificate is stored in - required for verification and decryption
  47. * @param fFile $private_key_file The file the S/MIME private key is stored in - required for decryption only
  48. * @param string $private_key_password The password for the private key
  49. * @return void
  50. */
  51. static public function addSMIMEPair($email_address, $certificate_file, $private_key_file=NULL, $private_key_password=NULL)
  52. {
  53. if ($private_key_file !== NULL && !$private_key_file instanceof fFile) {
  54. $private_key_file = new fFile($private_key_file);
  55. }
  56. if (!$certificate_file instanceof fFile) {
  57. $certificate_file = new fFile($certificate_file);
  58. }
  59. self::$smime_pairs[strtolower($email_address)] = array(
  60. 'certificate' => $certificate_file,
  61. 'private_key' => $private_key_file,
  62. 'password' => $private_key_password
  63. );
  64. }
  65. /**
  66. * Takes a date, removes comments and cleans up some common formatting inconsistencies
  67. *
  68. * @param string $date The date to clean
  69. * @return string The cleaned date
  70. */
  71. static private function cleanDate($date)
  72. {
  73. $date = preg_replace('#\([^)]+\)#', ' ', trim($date));
  74. $date = preg_replace('#\s+#', ' ', $date);
  75. $date = preg_replace('#(\d+)-([a-z]+)-(\d{4})#i', '\1 \2 \3', $date);
  76. $date = preg_replace('#^[a-z]+\s*,\s*#i', '', trim($date));
  77. return trim($date);
  78. }
  79. /**
  80. * Decodes encoded-word headers of any encoding into raw UTF-8
  81. *
  82. * @param string $text The header value to decode
  83. * @return string The decoded UTF-8
  84. */
  85. static private function decodeHeader($text)
  86. {
  87. $parts = preg_split('#(=\?[^\?]+\?[QB]\?[^\?]+\?=)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
  88. $part_with_encoding = array();
  89. $output = '';
  90. foreach ($parts as $part) {
  91. if ($part === '') {
  92. continue;
  93. }
  94. if (preg_match_all('#=\?([^\?]+)\?([QB])\?([^\?]+)\?=#i', $part, $matches, PREG_SET_ORDER)) {
  95. foreach ($matches as $match) {
  96. if (strtoupper($match[2]) == 'Q') {
  97. $part_string = rawurldecode(strtr(
  98. $match[3],
  99. array(
  100. '=' => '%',
  101. '_' => ' '
  102. )
  103. ));
  104. } else {
  105. $part_string = base64_decode($match[3]);
  106. }
  107. $lower_encoding = strtolower($match[1]);
  108. $last_key = count($part_with_encoding) - 1;
  109. if (isset($part_with_encoding[$last_key]) && $part_with_encoding[$last_key]['encoding'] == $lower_encoding) {
  110. $part_with_encoding[$last_key]['string'] .= $part_string;
  111. } else {
  112. $part_with_encoding[] = array('encoding' => $lower_encoding, 'string' => $part_string);
  113. }
  114. }
  115. } else {
  116. $last_key = count($part_with_encoding) - 1;
  117. if (isset($part_with_encoding[$last_key]) && $part_with_encoding[$last_key]['encoding'] == 'iso-8859-1') {
  118. $part_with_encoding[$last_key]['string'] .= $part;
  119. } else {
  120. $part_with_encoding[] = array('encoding' => 'iso-8859-1', 'string' => $part);
  121. }
  122. }
  123. }
  124. foreach ($part_with_encoding as $part) {
  125. $output .= self::iconv($part['encoding'], 'UTF-8', $part['string']);
  126. }
  127. return $output;
  128. }
  129. /**
  130. * Handles an individual part of a multipart message
  131. *
  132. * @param array $info An array of information about the message
  133. * @param array $structure An array describing the structure of the message
  134. * @return array The modified $info array
  135. */
  136. static private function handlePart($info, $structure)
  137. {
  138. if ($structure['type'] == 'multipart') {
  139. foreach ($structure['parts'] as $part) {
  140. $info = self::handlePart($info, $part);
  141. }
  142. return $info;
  143. }
  144. if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-mime', 'x-pkcs7-mime'))) {
  145. $to = NULL;
  146. if (isset($info['headers']['to'][0])) {
  147. $to = $info['headers']['to'][0]['mailbox'];
  148. if (!empty($info['headers']['to'][0]['host'])) {
  149. $to .= '@' . $info['headers']['to'][0]['host'];
  150. }
  151. }
  152. if ($to && !empty(self::$smime_pairs[$to]['private_key'])) {
  153. if (self::handleSMIMEDecryption($info, $structure, self::$smime_pairs[$to])) {
  154. return $info;
  155. }
  156. }
  157. }
  158. if ($structure['type'] == 'application' && in_array($structure['subtype'], array('pkcs7-signature', 'x-pkcs7-signature'))) {
  159. $from = NULL;
  160. if (isset($info['headers']['from'])) {
  161. $from = $info['headers']['from']['mailbox'];
  162. if (!empty($info['headers']['from']['host'])) {
  163. $from .= '@' . $info['headers']['from']['host'];
  164. }
  165. }
  166. if ($from && !empty(self::$smime_pairs[$from]['certificate'])) {
  167. if (self::handleSMIMEVerification($info, $structure, self::$smime_pairs[$from])) {
  168. return $info;
  169. }
  170. }
  171. }
  172. $data = $structure['data'];
  173. if ($structure['encoding'] == 'base64') {
  174. $content = '';
  175. foreach (explode("\r\n", $data) as $line) {
  176. $content .= base64_decode($line);
  177. }
  178. } elseif ($structure['encoding'] == 'quoted-printable') {
  179. $content = quoted_printable_decode($data);
  180. } else {
  181. $content = $data;
  182. }
  183. if ($structure['type'] == 'text') {
  184. $charset = 'iso-8859-1';
  185. foreach ($structure['type_fields'] as $field => $value) {
  186. if (strtolower($field) == 'charset') {
  187. $charset = $value;
  188. break;
  189. }
  190. }
  191. $content = self::iconv($charset, 'UTF-8', $content);
  192. if ($structure['subtype'] == 'html') {
  193. $content = preg_replace('#(content=(["\'])text/html\s*;\s*charset=(["\']?))' . preg_quote($charset, '#') . '(\3\2)#i', '\1utf-8\4', $content);
  194. }
  195. }
  196. // This indicates a content-id which is used for multipart/related
  197. if ($structure['content_id']) {
  198. if (!isset($info['related'])) {
  199. $info['related'] = array();
  200. }
  201. $cid = $structure['content_id'][0] == '<' ? substr($structure['content_id'], 1, -1) : $structure['content_id'];
  202. $info['related']['cid:' . $cid] = array(
  203. 'mimetype' => $structure['type'] . '/' . $structure['subtype'],
  204. 'data' => $content
  205. );
  206. return $info;
  207. }
  208. $has_disposition = !empty($structure['disposition']);
  209. $is_text = $structure['type'] == 'text' && $structure['subtype'] == 'plain';
  210. $is_html = $structure['type'] == 'text' && $structure['subtype'] == 'html';
  211. // If the part doesn't have a disposition and is not the default text or html, set the disposition to inline
  212. if (!$has_disposition && ((!$is_text || !empty($info['text'])) && (!$is_html || !empty($info['html'])))) {
  213. $is_web_image = $structure['type'] == 'image' && in_array($structure['subtype'], array('gif', 'png', 'jpeg', 'pjpeg'));
  214. $structure['disposition'] = $is_text || $is_html || $is_web_image ? 'inline' : 'attachment';
  215. $structure['disposition_fields'] = array();
  216. $has_disposition = TRUE;
  217. }
  218. // Attachments or inline content
  219. if ($has_disposition) {
  220. $filename = '';
  221. foreach ($structure['disposition_fields'] as $field => $value) {
  222. if (strtolower($field) == 'filename') {
  223. $filename = $value;
  224. break;
  225. }
  226. }
  227. foreach ($structure['type_fields'] as $field => $value) {
  228. if (strtolower($field) == 'name') {
  229. $filename = $value;
  230. break;
  231. }
  232. }
  233. // This automatically handles primary content that has a content-disposition header on it
  234. if ($structure['disposition'] == 'inline' && $filename === '') {
  235. if ($is_text && !isset($info['text'])) {
  236. $info['text'] = $content;
  237. return $info;
  238. }
  239. if ($is_html && !isset($info['html'])) {
  240. $info['html'] = $content;
  241. return $info;
  242. }
  243. }
  244. if (!isset($info[$structure['disposition']])) {
  245. $info[$structure['disposition']] = array();
  246. }
  247. $info[$structure['disposition']][] = array(
  248. 'filename' => $filename,
  249. 'mimetype' => $structure['type'] . '/' . $structure['subtype'],
  250. 'data' => $content
  251. );
  252. return $info;
  253. }
  254. if ($is_text) {
  255. $info['text'] = $content;
  256. return $info;
  257. }
  258. if ($is_html) {
  259. $info['html'] = $content;
  260. return $info;
  261. }
  262. }
  263. /**
  264. * Tries to decrypt an S/MIME message using a private key
  265. *
  266. * @param array &$info The array of information about a message
  267. * @param array $structure The structure of this part
  268. * @param array $smime_pair An associative array containing an S/MIME certificate, private key and password
  269. * @return boolean If the message was decrypted
  270. */
  271. static private function handleSMIMEDecryption(&$info, $structure, $smime_pair)
  272. {
  273. $plaintext_file = tempnam('', '__fMailbox_');
  274. $ciphertext_file = tempnam('', '__fMailbox_');
  275. $headers = array();
  276. $headers[] = "Content-Type: " . $structure['type'] . '/' . $structure['subtype'];
  277. $headers[] = "Content-Transfer-Encoding: " . $structure['encoding'];
  278. $header = "Content-Disposition: " . $structure['disposition'];
  279. foreach ($structure['disposition_fields'] as $field => $value) {
  280. $header .= '; ' . $field . '="' . $value . '"';
  281. }
  282. $headers[] = $header;
  283. file_put_contents($ciphertext_file, join("\r\n", $headers) . "\r\n\r\n" . $structure['data']);
  284. $private_key = openssl_pkey_get_private(
  285. $smime_pair['private_key']->read(),
  286. $smime_pair['password']
  287. );
  288. $certificate = $smime_pair['certificate']->read();
  289. $result = openssl_pkcs7_decrypt($ciphertext_file, $plaintext_file, $certificate, $private_key);
  290. unlink($ciphertext_file);
  291. if (!$result) {
  292. unlink($plaintext_file);
  293. return FALSE;
  294. }
  295. $contents = file_get_contents($plaintext_file);
  296. $info['raw_message'] = $contents;
  297. $info = self::handlePart($info, self::parseStructure($contents));
  298. $info['decrypted'] = TRUE;
  299. unlink($plaintext_file);
  300. return TRUE;
  301. }
  302. /**
  303. * Takes a message with an S/MIME signature and verifies it if possible
  304. *
  305. * @param array &$info The array of information about a message
  306. * @param array $structure
  307. * @param array $smime_pair An associative array containing an S/MIME certificate file
  308. * @return boolean If the message was verified
  309. */
  310. static private function handleSMIMEVerification(&$info, $structure, $smime_pair)
  311. {
  312. $certificates_file = tempnam('', '__fMailbox_');
  313. $ciphertext_file = tempnam('', '__fMailbox_');
  314. file_put_contents($ciphertext_file, $info['raw_message']);
  315. $result = openssl_pkcs7_verify(
  316. $ciphertext_file,
  317. PKCS7_NOINTERN | PKCS7_NOVERIFY,
  318. $certificates_file,
  319. array(),
  320. $smime_pair['certificate']->getPath()
  321. );
  322. unlink($ciphertext_file);
  323. unlink($certificates_file);
  324. if (!$result || $result === -1) {
  325. return FALSE;
  326. }
  327. $info['verified'] = TRUE;
  328. return TRUE;
  329. }
  330. /**
  331. * This works around a bug in MAMP 1.9.4+ and PHP 5.3 where iconv()
  332. * does not seem to properly assign the return value to a variable, but
  333. * does work when returning the value.
  334. *
  335. * @param string $in_charset The incoming character encoding
  336. * @param string $out_charset The outgoing character encoding
  337. * @param string $string The string to convert
  338. * @return string The converted string
  339. */
  340. static private function iconv($in_charset, $out_charset, $string)
  341. {
  342. return iconv($in_charset, $out_charset, $string);
  343. }
  344. /**
  345. * Joins parsed emails into a comma-delimited string
  346. *
  347. * @param array $emails An array of emails split into personal, mailbox and host parts
  348. * @return string An comma-delimited list of emails
  349. */
  350. static private function joinEmails($emails)
  351. {
  352. $output = '';
  353. foreach ($emails as $email) {
  354. if ($output) { $output .= ', '; }
  355. if (!isset($email[0])) {
  356. $email[0] = !empty($email['personal']) ? $email['personal'] : '';
  357. $email[2] = $email['mailbox'];
  358. $email[3] = !empty($email['host']) ? $email['host'] : '';
  359. }
  360. $address = $email[2];
  361. if (!empty($email[3])) {
  362. $address .= '@' . $email[3];
  363. }
  364. $output .= fEmail::combineNameEmail($email[0], $address);
  365. }
  366. return $output;
  367. }
  368. /**
  369. * Parses a string representation of an email into the persona, mailbox and host parts
  370. *
  371. * @param string $string The email string to parse
  372. * @return array An associative array with the key `mailbox`, and possibly `host` and `personal`
  373. */
  374. static private function parseEmail($string)
  375. {
  376. $email_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+")(?:\.[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+"[ \t]*))*)@((?:[a-z0-9\\-]+\.)+[a-z]{2,}|\[(?:(?:[01]?\d?\d|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d?\d|2[0-4]\d|25[0-5])\])';
  377. $name_regex = '((?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*)(?:\.?[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*))*)';
  378. if (preg_match('~^[ \t]*' . $name_regex . '[ \t]*<[ \t]*' . $email_regex . '[ \t]*>[ \t]*$~ixD', $string, $match)) {
  379. $match[1] = trim($match[1]);
  380. if ($match[1][0] == '"' && substr($match[1], -1) == '"') {
  381. $match[1] = substr($match[1], 1, -1);
  382. }
  383. return array(
  384. 'personal' => self::decodeHeader($match[1]),
  385. 'mailbox' => self::decodeHeader($match[2]),
  386. 'host' => self::decodeHeader($match[3])
  387. );
  388. } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*$~ixD', $string, $match)) {
  389. return array(
  390. 'mailbox' => self::decodeHeader($match[1]),
  391. 'host' => self::decodeHeader($match[2])
  392. );
  393. // This handles the outdated practice of including the personal
  394. // part of the email in a comment after the email address
  395. } elseif (preg_match('~^[ \t]*(?:<[ \t]*)?' . $email_regex . '(?:[ \t]*>)?[ \t]*\(([^)]+)\)[ \t]*$~ixD', $string, $match)) {
  396. $match[3] = trim($match[1]);
  397. if ($match[3][0] == '"' && substr($match[3], -1) == '"') {
  398. $match[3] = substr($match[3], 1, -1);
  399. }
  400. return array(
  401. 'personal' => self::decodeHeader($match[3]),
  402. 'mailbox' => self::decodeHeader($match[1]),
  403. 'host' => self::decodeHeader($match[2])
  404. );
  405. }
  406. if (strpos($string, '@') !== FALSE) {
  407. list ($mailbox, $host) = explode('@', $string, 2);
  408. return array(
  409. 'mailbox' => self::decodeHeader($mailbox),
  410. 'host' => self::decodeHeader($host)
  411. );
  412. }
  413. return array(
  414. 'mailbox' => self::decodeHeader($string),
  415. 'host' => ''
  416. );
  417. }
  418. /**
  419. * Parses full email headers into an associative array
  420. *
  421. * @param string $headers The header to parse
  422. * @param string $filter Remove any headers that match this
  423. * @return array The parsed headers
  424. */
  425. static private function parseHeaders($headers, $filter=NULL)
  426. {
  427. $header_lines = preg_split("#\r\n(?!\s)#", trim($headers));
  428. $single_email_fields = array('from', 'sender', 'reply-to');
  429. $multi_email_fields = array('to', 'cc');
  430. $additional_info_fields = array('content-type', 'content-disposition');
  431. $headers = array();
  432. foreach ($header_lines as $header_line) {
  433. $header_line = preg_replace("#\r\n\s+#", '', $header_line);
  434. list ($header, $value) = preg_split('#:\s*#', $header_line, 2);
  435. $header = strtolower($header);
  436. if (strpos($header, $filter) !== FALSE) {
  437. continue;
  438. }
  439. $is_single_email = in_array($header, $single_email_fields);
  440. $is_multi_email = in_array($header, $multi_email_fields);
  441. $is_additional_info_field = in_array($header, $additional_info_fields);
  442. if ($is_additional_info_field) {
  443. $pieces = preg_split('#;\s*#', $value, 2);
  444. $value = $pieces[0];
  445. $headers[$header] = array('value' => self::decodeHeader($value));
  446. $fields = array();
  447. if (!empty($pieces[1])) {
  448. preg_match_all('#(\w+)=("([^"]+)"|([^\s;]+))(?=;|$)#', $pieces[1], $matches, PREG_SET_ORDER);
  449. foreach ($matches as $match) {
  450. $fields[$match[1]] = self::decodeHeader(!empty($match[4]) ? $match[4] : $match[3]);
  451. }
  452. }
  453. $headers[$header]['fields'] = $fields;
  454. } elseif ($is_single_email) {
  455. $headers[$header] = self::parseEmail($value);
  456. } elseif ($is_multi_email) {
  457. $strings = array();
  458. preg_match_all('#"[^"]+?"#', $value, $matches, PREG_SET_ORDER);
  459. foreach ($matches as $i => $match) {
  460. $strings[] = $match[0];
  461. $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1);
  462. }
  463. preg_match_all('#\([^)]+?\)#', $value, $matches, PREG_SET_ORDER);
  464. foreach ($matches as $i => $match) {
  465. $strings[] = $match[0];
  466. $value = preg_replace('#' . preg_quote($match[0], '#') . '#', ':string' . sizeof($strings), $value, 1);
  467. }
  468. $emails = explode(',', $value);
  469. array_map('trim', $emails);
  470. foreach ($strings as $i => $string) {
  471. $emails = preg_replace(
  472. '#:string' . ($i+1) . '\b#',
  473. strtr($string, array('\\' => '\\\\', '$' => '\\$')),
  474. $emails,
  475. 1
  476. );
  477. }
  478. $headers[$header] = array();
  479. foreach ($emails as $email) {
  480. $headers[$header][] = self::parseEmail($email);
  481. }
  482. } elseif ($header == 'references') {
  483. $headers[$header] = array_map(array('fMailbox', 'decodeHeader'), preg_split('#(?<=>)\s+(?=<)#', $value));
  484. } elseif ($header == 'received') {
  485. if (!isset($headers[$header])) {
  486. $headers[$header] = array();
  487. }
  488. $headers[$header][] = preg_replace('#\s+#', ' ', self::decodeHeader($value));
  489. } else {
  490. $headers[$header] = self::decodeHeader($value);
  491. }
  492. }
  493. return $headers;
  494. }
  495. /**
  496. * Parses a MIME message into an associative array of information
  497. *
  498. * The output includes the following keys:
  499. *
  500. * - `'received'`: The date the message was received by the server
  501. * - `'headers'`: An associative array of mail headers, the keys are the header names, in lowercase
  502. *
  503. * And one or more of the following:
  504. *
  505. * - `'text'`: The plaintext body
  506. * - `'html'`: The HTML body
  507. * - `'attachment'`: An array of attachments, each containing:
  508. * - `'filename'`: The name of the file
  509. * - `'mimetype'`: The mimetype of the file
  510. * - `'data'`: The raw contents of the file
  511. * - `'inline'`: An array of inline files, each containing:
  512. * - `'filename'`: The name of the file
  513. * - `'mimetype'`: The mimetype of the file
  514. * - `'data'`: The raw contents of the file
  515. * - `'related'`: An associative array of related files, such as embedded images, with the key `'cid:{content-id}'` and an array value containing:
  516. * - `'mimetype'`: The mimetype of the file
  517. * - `'data'`: The raw contents of the file
  518. * - `'verified'`: If the message contents were verified via an S/MIME certificate - if not verified the smime.p7s will be listed as an attachment
  519. * - `'decrypted'`: If the message contents were decrypted via an S/MIME private key - if not decrypted the smime.p7m will be listed as an attachment
  520. *
  521. * All values in `headers`, `text` and `body` will have been decoded to
  522. * UTF-8. Files in the `attachment`, `inline` and `related` array will all
  523. * retain their original encodings.
  524. *
  525. * @param string $message The full source of the email message
  526. * @param boolean $convert_newlines If `\r\n` should be converted to `\n` in the `text` and `html` parts the message
  527. * @return array The parsed email message - see method description for details
  528. */
  529. static public function parseMessage($message, $convert_newlines=FALSE)
  530. {
  531. $info = array();
  532. list ($headers, $body) = explode("\r\n\r\n", $message, 2);
  533. $parsed_headers = self::parseHeaders($headers);
  534. $info['received'] = self::cleanDate(preg_replace('#^.*;\s*([^;]+)$#', '\1', $parsed_headers['received'][0]));
  535. $info['headers'] = array();
  536. foreach ($parsed_headers as $header => $value) {
  537. if (substr($header, 0, 8) == 'content-') {
  538. continue;
  539. }
  540. $info['headers'][$header] = $value;
  541. }
  542. $info['raw_headers'] = $headers;
  543. $info['raw_message'] = $message;
  544. $info = self::handlePart($info, self::parseStructure($body, $parsed_headers));
  545. unset($info['raw_message']);
  546. unset($info['raw_headers']);
  547. if ($convert_newlines) {
  548. if (isset($info['text'])) {
  549. $info['text'] = str_replace("\r\n", "\n", $info['text']);
  550. }
  551. if (isset($info['html'])) {
  552. $info['html'] = str_replace("\r\n", "\n", $info['html']);
  553. }
  554. }
  555. if (isset($info['text'])) {
  556. $info['text'] = preg_replace('#\r?\n$#D', '', $info['text']);
  557. }
  558. if (isset($info['html'])) {
  559. $info['html'] = preg_replace('#\r?\n$#D', '', $info['html']);
  560. }
  561. return $info;
  562. }
  563. /**
  564. * Takes a response from an IMAP command and parses it into a
  565. * multi-dimensional array
  566. *
  567. * @param string $text The IMAP command response
  568. * @param boolean $top_level If we are parsing the top level
  569. * @return array The parsed representation of the response text
  570. */
  571. static private function parseResponse($text, $top_level=FALSE)
  572. {
  573. $regex = '[\\\\\w.\[\]]+|"([^"\\\\]+|\\\\"|\\\\\\\\)*"|\((?:(?1)[ \t]*)*\)';
  574. if (preg_match('#\{(\d+)\}#', $text, $match)) {
  575. $regex = '\{' . $match[1] . '\}\r\n.{' . ($match[1]) . '}|' . $regex;
  576. }
  577. preg_match_all('#(' . $regex . ')#s', $text, $matches, PREG_SET_ORDER);
  578. $output = array();
  579. foreach ($matches as $match) {
  580. if (substr($match[0], 0, 1) == '"') {
  581. $output[] = str_replace('\\"', '"', substr($match[0], 1, -1));
  582. } elseif (substr($match[0], 0, 1) == '(') {
  583. $output[] = self::parseResponse(substr($match[0], 1, -1));
  584. } elseif (substr($match[0], 0, 1) == '{') {
  585. $output[] = preg_replace('#^[^\r]+\r\n#', '', $match[0]);
  586. } else {
  587. $output[] = $match[0];
  588. }
  589. }
  590. if ($top_level) {
  591. $new_output = array();
  592. $total_size = count($output);
  593. for ($i = 0; $i < $total_size; $i = $i + 2) {
  594. $new_output[strtolower($output[$i])] = $output[$i+1];
  595. }
  596. $output = $new_output;
  597. }
  598. return $output;
  599. }
  600. /**
  601. * Takes the raw contents of a MIME message and creates an array that
  602. * describes the structure of the message
  603. *
  604. * @param string $data The contents to get the structure of
  605. * @param string $headers The parsed headers for the message - if not present they will be extracted from the `$data`
  606. * @return array The multi-dimensional, associative array containing the message structure
  607. */
  608. static private function parseStructure($data, $headers=NULL)
  609. {
  610. if (!$headers) {
  611. list ($headers, $data) = explode("\r\n\r\n", $data, 2);
  612. $headers = self::parseHeaders($headers);
  613. }
  614. if (!isset($headers['content-type'])) {
  615. $headers['content-type'] = array(
  616. 'value' => 'text/plain',
  617. 'fields' => array()
  618. );
  619. }
  620. list ($type, $subtype) = explode('/', strtolower($headers['content-type']['value']), 2);
  621. if ($type == 'multipart') {
  622. $structure = array(
  623. 'type' => $type,
  624. 'subtype' => $subtype,
  625. 'parts' => array()
  626. );
  627. $boundary = $headers['content-type']['fields']['boundary'];
  628. $start_pos = strpos($data, '--' . $boundary) + strlen($boundary) + 4;
  629. $end_pos = strrpos($data, '--' . $boundary . '--') - 2;
  630. $sub_contents = explode("\r\n--" . $boundary . "\r\n", substr(
  631. $data,
  632. $start_pos,
  633. $end_pos - $start_pos
  634. ));
  635. foreach ($sub_contents as $sub_content) {
  636. $structure['parts'][] = self::parseStructure($sub_content);
  637. }
  638. } else {
  639. $structure = array(
  640. 'type' => $type,
  641. 'type_fields' => !empty($headers['content-type']['fields']) ? $headers['content-type']['fields'] : array(),
  642. 'subtype' => $subtype,
  643. 'content_id' => isset($headers['content-id']) ? $headers['content-id'] : NULL,
  644. 'encoding' => isset($headers['content-transfer-encoding']) ? strtolower($headers['content-transfer-encoding']) : '8bit',
  645. 'disposition' => isset($headers['content-disposition']) ? strtolower($headers['content-disposition']['value']) : NULL,
  646. 'disposition_fields' => isset($headers['content-disposition']) ? $headers['content-disposition']['fields'] : array(),
  647. 'data' => $data
  648. );
  649. }
  650. return $structure;
  651. }
  652. /**
  653. * Resets the configuration of the class
  654. *
  655. * @internal
  656. *
  657. * @return void
  658. */
  659. static public function reset()
  660. {
  661. self::$smime_pairs = array();
  662. }
  663. /**
  664. * Takes an associative array and unfolds the keys and values so that the
  665. * result in an integer-indexed array of `0 => key1, 1 => value1, 2 => key2,
  666. * 3 => value2, ...`.
  667. *
  668. * @param array $array The array to unfold
  669. * @return array The unfolded array
  670. */
  671. static private function unfoldAssociativeArray($array)
  672. {
  673. $new_array = array();
  674. foreach ($array as $key => $value) {
  675. $new_array[] = $key;
  676. $new_array[] = $value;
  677. }
  678. return $new_array;
  679. }
  680. /**
  681. * A counter to use for generating command keys
  682. *
  683. * @var integer
  684. */
  685. private $command_num = 1;
  686. /**
  687. * The connection resource
  688. *
  689. * @var resource
  690. */
  691. private $connection;
  692. /**
  693. * If debugging has been enabled
  694. *
  695. * @var boolean
  696. */
  697. private $debug;
  698. /**
  699. * The server hostname or IP address
  700. *
  701. * @var string
  702. */
  703. private $host;
  704. /**
  705. * The password for the account
  706. *
  707. * @var string
  708. */
  709. private $password;
  710. /**
  711. * The port for the server
  712. *
  713. * @var integer
  714. */
  715. private $port;
  716. /**
  717. * If the connection to the server should be secure
  718. *
  719. * @var boolean
  720. */
  721. private $secure;
  722. /**
  723. * The timeout for the connection
  724. *
  725. * @var integer
  726. */
  727. private $timeout = 5;
  728. /**
  729. * The type of mailbox, `'imap'` or `'pop3'`
  730. *
  731. * @var string
  732. */
  733. private $type;
  734. /**
  735. * The username for the account
  736. *
  737. * @var string
  738. */
  739. private $username;
  740. /**
  741. * Configures the connection to the server
  742. *
  743. * Please note that the GMail POP3 server does not act like other POP3
  744. * servers and the GMail IMAP server should be used instead. GMail POP3 only
  745. * allows retrieving a message once - during future connections the email
  746. * in question will no longer be available.
  747. *
  748. * @param string $type The type of mailbox, `'pop3'` or `'imap'`
  749. * @param string $host The server hostname or IP address
  750. * @param string $username The user to log in as
  751. * @param string $password The user's password
  752. * @param integer $port The port to connect via - only required if non-standard
  753. * @param boolean $secure If SSL should be used for the connection - this requires the `openssl` extension
  754. * @param integer $timeout The timeout to use when connecting
  755. * @return fMailbox
  756. */
  757. public function __construct($type, $host, $username, $password, $port=NULL, $secure=FALSE, $timeout=NULL)
  758. {
  759. if ($timeout === NULL) {
  760. $timeout = ini_get('default_socket_timeout');
  761. }
  762. $valid_types = array('imap', 'pop3');
  763. if (!in_array($type, $valid_types)) {
  764. throw new fProgrammerException(
  765. 'The mailbox type specified, %1$s, in invalid. Must be one of: %2$s.',
  766. $type,
  767. join(', ', $valid_types)
  768. );
  769. }
  770. if ($port === NULL) {
  771. if ($type == 'imap') {
  772. $port = !$secure ? 143 : 993;
  773. } else {
  774. $port = !$secure ? 110 : 995;
  775. }
  776. }
  777. if ($secure && !extension_loaded('openssl')) {
  778. throw new fEnvironmentException(
  779. 'A secure connection was requested, but the %s extension is not installed',
  780. 'openssl'
  781. );
  782. }
  783. $this->type = $type;
  784. $this->host = $host;
  785. $this->username = $username;
  786. $this->password = $password;
  787. $this->port = $port;
  788. $this->secure = $secure;
  789. $this->timeout = $timeout;
  790. }
  791. /**
  792. * Disconnects from the server
  793. *
  794. * @return void
  795. */
  796. public function __destruct()
  797. {
  798. $this->close();
  799. }
  800. /**
  801. * Closes the connection to the server
  802. *
  803. * @return void
  804. */
  805. public function close()
  806. {
  807. if (!$this->connection) {
  808. return;
  809. }
  810. if ($this->type == 'imap') {
  811. $this->write('LOGOUT');
  812. } else {
  813. $this->write('QUIT', 1);
  814. }
  815. $this->connection = NULL;
  816. }
  817. /**
  818. * Connects to the server
  819. *
  820. * @return void
  821. */
  822. private function connect()
  823. {
  824. if ($this->connection) {
  825. return;
  826. }
  827. fCore::startErrorCapture(E_WARNING);
  828. $this->connection = fsockopen(
  829. $this->secure ? 'tls://' . $this->host : $this->host,
  830. $this->port,
  831. $error_number,
  832. $error_string,
  833. $this->timeout
  834. );
  835. foreach (fCore::stopErrorCapture('#ssl#i') as $error) {
  836. 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.');
  837. }
  838. if (!$this->connection) {
  839. throw new fConnectivityException('There was an error connecting to the server');
  840. }
  841. stream_set_timeout($this->connection, $this->timeout);
  842. if ($this->type == 'imap') {
  843. if (!$this->secure && extension_loaded('openssl')) {
  844. $response = $this->write('CAPABILITY');
  845. if (preg_match('#\bstarttls\b#i', $response[0])) {
  846. $this->write('STARTTLS');
  847. do {
  848. if (isset($res)) {
  849. sleep(0.1);
  850. }
  851. $res = stream_socket_enable_crypto($this->connection, TRUE, STREAM_CRYPTO_METHOD_TLS_CLIENT);
  852. } while ($res === 0);
  853. }
  854. }
  855. $response = $this->write('LOGIN ' . $this->username . ' ' . $this->password);
  856. if (!$response || !preg_match('#^[^ ]+\s+OK#', $response[count($response)-1])) {
  857. throw new fValidationException(
  858. 'The username and password provided were not accepted for the %1$s server %2$s on port %3$s',
  859. strtoupper($this->type),
  860. $this->host,
  861. $this->port
  862. );
  863. }
  864. $this->write('SELECT "INBOX"');
  865. } elseif ($this->type == 'pop3') {
  866. $response = $this->read(1);
  867. if (isset($response[0])) {
  868. if ($response[0][0] == '-') {
  869. throw new fConnectivityException(
  870. 'There was an error connecting to the POP3 server %1$s on port %2$s',
  871. $this->host,
  872. $this->port
  873. );
  874. }
  875. preg_match('#<[^@]+@[^>]+>#', $response[0], $match);
  876. }
  877. if (!$this->secure && extension_loaded('openssl')) {
  878. $response = $this->write('STLS', 1);
  879. if ($response[0][0] == '+') {
  880. do {
  881. if (isset($res)) {
  882. sleep(0.1);
  883. }
  884. $res = stream_socket_enable_crypto($this->connection, TRUE, STREAM_CRYPTO_METHOD_TLS_CLIENT);
  885. } while ($res === 0);
  886. if ($res === FALSE) {
  887. throw new fConnectivityException('Error establishing secure connection');
  888. }
  889. }
  890. }
  891. $authenticated = FALSE;
  892. if (isset($match[0])) {
  893. $response = $this->write('APOP ' . $this->username . ' ' . md5($match[0] . $this->password), 1);
  894. if (isset($response[0]) && $response[0][0] == '+') {
  895. $authenticated = TRUE;
  896. }
  897. }
  898. if (!$authenticated) {
  899. $response = $this->write('USER ' . $this->username, 1);
  900. if ($response[0][0] == '+') {
  901. $response = $this->write('PASS ' . $this->password, 1);
  902. if (isset($response[0][0]) && $response[0][0] == '+') {
  903. $authenticated = TRUE;
  904. }
  905. }
  906. }
  907. if (!$authenticated) {
  908. throw new fValidationException(
  909. 'The username and password provided were not accepted for the %1$s server %2$s on port %3$s',
  910. strtoupper($this->type),
  911. $this->host,
  912. $this->port
  913. );
  914. }
  915. }
  916. }
  917. /**
  918. * Deletes one or more messages from the server
  919. *
  920. * Passing more than one UID at a time is more efficient for IMAP mailboxes,
  921. * whereas POP3 mailboxes will see no difference in performance.
  922. *
  923. * @param integer|array $uid The UID(s) of the message(s) to delete
  924. * @return void
  925. */
  926. public function deleteMessages($uid)
  927. {
  928. $this->connect();
  929. settype($uid, 'array');
  930. if ($this->type == 'imap') {
  931. $this->write('UID STORE ' . join(',', $uid) . ' +FLAGS (\\Deleted)');
  932. $this->write('EXPUNGE');
  933. } elseif ($this->type == 'pop3') {
  934. foreach ($uid as $id) {
  935. $this->write('DELE ' . $id, 1);
  936. }
  937. }
  938. }
  939. /**
  940. * Sets if debug messages should be shown
  941. *
  942. * @param boolean $flag If debugging messages should be shown
  943. * @return void
  944. */
  945. public function enableDebugging($flag)
  946. {
  947. $this->debug = (boolean) $flag;
  948. }
  949. /**
  950. * Retrieves a single message from the server
  951. *
  952. * The output includes the following keys:
  953. *
  954. * - `'uid'`: The UID of the message
  955. * - `'received'`: The date the message was received by the server
  956. * - `'headers'`: An associative array of mail headers, the keys are the header names, in lowercase
  957. *
  958. * And one or more of the following:
  959. *
  960. * - `'text'`: The plaintext body
  961. * - `'html'`: The HTML body
  962. * - `'attachment'`: An array of attachments, each containing:
  963. * - `'filename'`: The name of the file
  964. * - `'mimetype'`: The mimetype of the file
  965. * - `'data'`: The raw contents of the file
  966. * - `'inline'`: An array of inline files, each containing:
  967. * - `'filename'`: The name of the file
  968. * - `'mimetype'`: The mimetype of the file
  969. * - `'data'`: The raw contents of the file
  970. * - `'related'`: An associative array of related files, such as embedded images, with the key `'cid:{content-id}'` and an array value containing:
  971. * - `'mimetype'`: The mimetype of the file
  972. * - `'data'`: The raw contents of the file
  973. * - `'verified'`: If the message contents were verified via an S/MIME certificate - if not verified the smime.p7s will be listed as an attachment
  974. * - `'decrypted'`: If the message contents were decrypted via an S/MIME private key - if not decrypted the smime.p7m will be listed as an attachment
  975. *
  976. * All values in `headers`, `text` and `body` will have been decoded to
  977. * UTF-8. Files in the `attachment`, `inline` and `related` array will all
  978. * retain their original encodings.
  979. *
  980. * @param integer $uid The UID of the message to retrieve
  981. * @param boolean $convert_newlines If `\r\n` should be converted to `\n` in the `text` and `html` parts the message
  982. * @return array The parsed email message - see method description for details
  983. */
  984. public function fetchMessage($uid, $convert_newlines=FALSE)
  985. {
  986. $this->connect();
  987. if ($this->type == 'imap') {
  988. $response = $this->write('UID FETCH ' . $uid . ' (BODY[])');
  989. preg_match('#\{(\d+)\}$#', $response[0], $match);
  990. $message = '';
  991. foreach ($response as $i => $line) {
  992. if (!$i) { continue; }
  993. if (strlen($message) + strlen($line) + 2 > $match[1]) {
  994. $message .= substr($line . "\r\n", 0, $match[1] - strlen($message));
  995. } else {
  996. $message .= $line . "\r\n";
  997. }
  998. }
  999. $info = self::parseMessage($message, $convert_newlines);
  1000. $info['uid'] = $uid;
  1001. } elseif ($this->type == 'pop3') {
  1002. $response = $this->write('RETR ' . $uid);
  1003. array_shift($response);
  1004. $response = join("\r\n", $response);
  1005. $info = self::parseMessage($response, $convert_newlines);
  1006. $info['uid'] = $uid;
  1007. }
  1008. return $info;
  1009. }
  1010. /**
  1011. * Gets a list of messages from the server
  1012. *
  1013. * The structure of the returned array is:
  1014. *
  1015. * {{{
  1016. * array(
  1017. * (integer) {uid} => array(
  1018. * 'uid' => (integer) {a unique identifier for this message on this server},
  1019. * 'received' => (string) {date message was received},
  1020. * 'size' => (integer) {size of message in bytes},
  1021. * 'date' => (string) {date message was sent},
  1022. * 'from' => (string) {the from header value},
  1023. * 'subject' => (string) {the message subject},
  1024. * 'message_id' => (string) {optional - the message-id header value, should be globally unique},
  1025. * 'to' => (string) {optional - the to header value},
  1026. * 'in_reply_to' => (string) {optional - the in-reply-to header value}
  1027. * ), ...
  1028. * )
  1029. * }}}
  1030. *
  1031. * All values will have been decoded to UTF-8.
  1032. *
  1033. * @param integer $limit The number of messages to retrieve
  1034. * @param integer $page The page of messages to retrieve
  1035. * @return array A list of messages on the server - see method description for details
  1036. */
  1037. public function listMessages($limit=NULL, $page=NULL)
  1038. {
  1039. $this->connect();
  1040. if ($this->type == 'imap') {
  1041. if (!$limit) {
  1042. $start = 1;
  1043. $end = '*';
  1044. } else {
  1045. if (!$page) {
  1046. $page = 1;
  1047. }
  1048. $start = ($limit * ($page-1)) + 1;
  1049. $end = $start + $limit - 1;
  1050. }
  1051. $total_messages = 0;
  1052. $response = $this->write('STATUS "INBOX" (MESSAGES)');
  1053. foreach ($response as $line) {
  1054. if (preg_match('#^\s*\*\s+STATUS\s+"?INBOX"?\s+\((.*)\)$#', $line, $match)) {
  1055. $details = self::parseResponse($match[1], TRUE);
  1056. $total_messages = $details['messages'];
  1057. }
  1058. }
  1059. if ($start > $total_messages) {
  1060. return array();
  1061. }
  1062. if ($end > $total_messages) {
  1063. $end = $total_messages;
  1064. }
  1065. $output = array();
  1066. $response = $this->write('FETCH ' . $start . ':' . $end . ' (UID INTERNALDATE RFC822.SIZE ENVELOPE)');
  1067. foreach ($response as $line) {
  1068. if (preg_match('#^\s*\*\s+(\d+)\s+FETCH\s+\((.*)\)$#', $line, $match)) {
  1069. $details = self::parseResponse($match[2], TRUE);
  1070. $info = array();
  1071. $info['uid'] = $details['uid'];
  1072. $info['received'] = self::cleanDate($details['internaldate']);
  1073. $info['size'] = $details['rfc822.size'];
  1074. $envelope = $details['envelope'];
  1075. $info['date'] = $envelope[0] != 'NIL' ? $envelope[0] : '';
  1076. $info['from'] = self::joinEmails($envelope[2]);
  1077. if (preg_match('#=\?[^\?]+\?[QB]\?[^\?]+\?=#', $envelope[1])) {
  1078. do {
  1079. $last_subject = $envelope[1];
  1080. $envelope[1] = preg_replace('#(=\?([^\?]+)\?[QB]\?[^\?]+\?=) (\s*=\?\2)#', '\1\3', $envelope[1]);
  1081. } while ($envelope[1] != $last_subject);
  1082. $info['subject'] = self::decodeHeader($envelope[1]);
  1083. } else {
  1084. $info['subject'] = $envelope[1] == 'NIL' ? '' : self::decodeHeader($envelope[1]);
  1085. }
  1086. if ($envelope[9] != 'NIL') {
  1087. $info['message_id'] = $envelope[9];
  1088. }
  1089. if ($envelope[5] != 'NIL') {
  1090. $info['to'] = self::joinEmails($envelope[5]);
  1091. }
  1092. if ($envelope[8] != 'NIL') {
  1093. $info['in_reply_to'] = $envelope[8];
  1094. }
  1095. $output[$info['uid']] = $info;
  1096. }
  1097. }
  1098. } elseif ($this->type == 'pop3') {
  1099. if (!$limit) {
  1100. $start = 1;
  1101. $end = NULL;
  1102. } else {
  1103. if (!$page) {
  1104. $page = 1;
  1105. }
  1106. $start = ($limit * ($page-1)) + 1;
  1107. $end = $start + $limit - 1;
  1108. }
  1109. $total_messages = 0;
  1110. $response = $this->write('STAT', 1);
  1111. preg_match('#^\+OK\s+(\d+)\s+#', $response[0], $match);
  1112. $total_messages = $match[1];
  1113. if ($start > $total_messages) {
  1114. return array();
  1115. }
  1116. if ($end === NULL || $end > $total_messages) {
  1117. $end = $total_messages;
  1118. }
  1119. $sizes = array();
  1120. $response = $this->write('LIST');
  1121. array_shift($response);
  1122. foreach ($response as $line) {
  1123. preg_match('#^(\d+)\s+(\d+)$#', $line, $match);
  1124. $sizes[$match[1]] = $match[2];
  1125. }
  1126. $output = array();
  1127. for ($i = $start; $i <= $end; $i++) {
  1128. $response = $this->write('TOP ' . $i . ' 1');
  1129. array_shift($response);
  1130. $value = array_pop($response);
  1131. // Some servers add an extra blank line after the 1 requested line
  1132. if (trim($value) == '') {
  1133. array_pop($response);
  1134. }
  1135. $response = trim(join("\r\n", $response));
  1136. $headers = self::parseHeaders($response);
  1137. $output[$i] = array(
  1138. 'uid' => $i,
  1139. 'received' => self::cleanDate(preg_replace('#^.*;\s*([^;]+)$#', '\1', $headers['received'][0])),
  1140. 'size' => $sizes[$i],
  1141. 'date' => $headers['date'],
  1142. 'from' => self::joinEmails(array($headers['from'])),
  1143. 'subject' => isset($headers['subject']) ? $headers['subject'] : ''
  1144. );
  1145. if (isset($headers['message-id'])) {
  1146. $output[$i]['message_id'] = $headers['message-id'];
  1147. }
  1148. if (isset($headers['to'])) {
  1149. $output[$i]['to'] = self::joinEmails($headers['to']);
  1150. }
  1151. if (isset($headers['in-reply-to'])) {
  1152. $output[$i]['in_reply_to'] = $headers['in-reply-to'];
  1153. }
  1154. }
  1155. }
  1156. return $output;
  1157. }
  1158. /**
  1159. * Reads responses from the server
  1160. *
  1161. * @param integer|string $expect The expected number of lines of response or a regex of the last line
  1162. * @return array The lines of response from the server
  1163. */
  1164. private function read($expect=NULL)
  1165. {
  1166. $read = array($this->connection);
  1167. $write = NULL;
  1168. $except = NULL;
  1169. $response = array();
  1170. // PHP 5.2.0 to 5.2.5 has a bug on amd64 linux where stream_select()
  1171. // fails, so we have to fake it - http://bugs.php.net/bug.php?id=42682
  1172. static $broken_select = NULL;
  1173. if ($broken_select === NULL) {
  1174. $broken_select = strpos(php_uname('m'), '64') !== FALSE && fCore::checkVersion('5.2.0') && !fCore::checkVersion('5.2.6');
  1175. }
  1176. // Fixes an issue with stream_select throwing a warning on PHP 5.3 on Windows
  1177. if (fCore::checkOS('windows') && fCore::checkVersion('5.3.0')) {
  1178. $select = @stream_select($read, $write, $except, $this->timeout);
  1179. } elseif ($broken_select) {
  1180. $broken_select_buffer = NULL;
  1181. $start_time = microtime(TRUE);
  1182. $i = 0;
  1183. do {
  1184. if ($i) {
  1185. usleep(50000);
  1186. }
  1187. $char = fgetc($this->connection);
  1188. if ($char != "\x00" && $char !== FALSE) {
  1189. $broken_select_buffer = $char;
  1190. }
  1191. $i++;
  1192. } while ($broken_select_buffer === NULL && microtime(TRUE) - $start_time < $this->timeout);
  1193. $select = $broken_select_buffer !== NULL;
  1194. } else {
  1195. $select = stream_select($read, $write, $except, $this->timeout);
  1196. }
  1197. if ($select) {
  1198. while (!feof($this->connection)) {
  1199. $line = fgets($this->connection);
  1200. if ($line === FALSE) {
  1201. break;
  1202. }
  1203. $line = substr($line, 0, -2);
  1204. // When we fake select, we have to handle what we've retrieved
  1205. if ($broken_select && $broken_select_buffer !== NULL) {
  1206. $line = $broken_select_buffer . $line;
  1207. $broken_select_buffer = NULL;
  1208. }
  1209. $response[] = $line;
  1210. // Automatically stop at the termination octet or a bad response
  1211. if ($this->type == 'pop3' && ($line == '.' || (count($response) == 1 && $response[0][0] == '-'))) {
  1212. break;
  1213. }
  1214. if ($expect !== NULL) {
  1215. $matched_number = is_int($expect) && sizeof($response) == $expect;
  1216. $matched_regex = is_string($expect) && preg_match($expect, $line);
  1217. if ($matched_number || $matched_regex) {
  1218. break;
  1219. }
  1220. }
  1221. }
  1222. }
  1223. if (fCore::getDebug($this->debug)) {
  1224. fCore::debug("Received:\n" . join("\r\n", $response), $this->debug);
  1225. }
  1226. if ($this->type == 'pop3') {
  1227. // Remove the termination octet
  1228. if ($response && $response[sizeof($response)-1] == '.') {
  1229. $response = array_slice($response, 0, -1);
  1230. }
  1231. // Remove byte-stuffing
  1232. $lines = count($response);
  1233. for ($i = 0; $i < $lines; $i++) {
  1234. if (strlen($response[$i]) && $response[$i][0] == '.') {
  1235. $response[$i] = substr($response[$i], 1);
  1236. }
  1237. }
  1238. }
  1239. return $response;
  1240. }
  1241. /**
  1242. * Sends commands to the IMAP or POP3 server
  1243. *
  1244. * @param string $command The command to send
  1245. * @param integer $expected The number of lines or regex expected for a POP3 command
  1246. * @return array The response from the server
  1247. */
  1248. private function write($command, $expected=NULL)
  1249. {
  1250. if (!$this->connection) {
  1251. throw new fProgrammerException('Unable to send data since the connection has already been closed');
  1252. }
  1253. if ($this->type == 'imap') {
  1254. $identifier = 'a' . str_pad($this->command_num++, 4, '0', STR_PAD_LEFT);
  1255. $command = $identifier . ' ' . $command;
  1256. }
  1257. if (substr($command, -2) != "\r\n") {
  1258. $command .= "\r\n";
  1259. }
  1260. if (fCore::getDebug($this->debug)) {
  1261. fCore::debug("Sending:\n" . trim($command), $this->debug);
  1262. }
  1263. $res = fwrite($this->connection, $command);
  1264. if ($res === FALSE || $res === 0) {
  1265. throw new fConnectivityException(
  1266. 'Unable to write data to %1$s server %2$s on port %3$s',
  1267. strtoupper($this->type),
  1268. $this->host,
  1269. $this->port
  1270. );
  1271. }
  1272. if ($this->type == 'imap') {
  1273. return $this->read('#^' . $identifier . '#');
  1274. } elseif ($this->type == 'pop3') {
  1275. return $this->read($expected);
  1276. }
  1277. }
  1278. }
  1279. /**
  1280. * Copyright (c) 2010-2011 Will Bond <will@flourishlib.com>
  1281. *
  1282. * Permission is hereby granted, free of charge, to any person obtaining a copy
  1283. * of this software and associated documentation files (the "Software"), to deal
  1284. * in the Software without restriction, including without limitation the rights
  1285. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  1286. * copies of the Software, and to permit persons to whom the Software is
  1287. * furnished to do so, subject to the following conditions:
  1288. *
  1289. * The above copyright notice and this permission notice shall be included in
  1290. * all copies or substantial portions of the Software.
  1291. *
  1292. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  1293. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  1294. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  1295. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  1296. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  1297. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  1298. * THE SOFTWARE.
  1299. */