root/fMailbox.php

Revision 901, 40.7 kB (checked in by wbond, 2 hours ago)

Completed ticket #499 - fixed a typo in fMailbox

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