PageRenderTime 55ms CodeModel.GetById 19ms RepoModel.GetById 0ms app.codeStats 1ms

/helpers/fMailbox/fMailbox.php

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