source: github/program/include/rcube_mime.php @ 77c779a

HEADcourier-fixdev-browser-capabilitiespdorelease-0.8
Last change on this file since 77c779a was 77c779a, checked in by thomascube <thomas@…>, 16 months ago

Update requirements list, remove TODO

  • Property mode set to 100644
File size: 17.7 KB
Line 
1<?php
2
3/*
4 +-----------------------------------------------------------------------+
5 | program/include/rcube_mime.php                                        |
6 |                                                                       |
7 | This file is part of the Roundcube Webmail client                     |
8 | Copyright (C) 2005-2012, The Roundcube Dev Team                       |
9 | Copyright (C) 2011-2012, Kolab Systems AG                             |
10 |                                                                       |
11 | Licensed under the GNU General Public License version 3 or            |
12 | any later version with exceptions for skins & plugins.                |
13 | See the README file for a full license statement.                     |
14 |                                                                       |
15 | PURPOSE:                                                              |
16 |   MIME message parsing utilities                                      |
17 |                                                                       |
18 +-----------------------------------------------------------------------+
19 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
20 | Author: Aleksander Machniak <alec@alec.pl>                            |
21 +-----------------------------------------------------------------------+
22
23 $Id$
24
25*/
26
27
28/**
29 * Class for parsing MIME messages
30 *
31 * @package Mail
32 * @author  Thomas Bruederli <roundcube@gmail.com>
33 * @author  Aleksander Machniak <alec@alec.pl>
34 */
35class rcube_mime
36{
37    private static $default_charset = RCMAIL_CHARSET;
38
39
40    /**
41     * Object constructor.
42     */
43    function __construct($default_charset = null)
44    {
45        if ($default_charset) {
46            self::$default_charset = $default_charset;
47        }
48        else {
49            self::$default_charset = rcmail::get_instance()->config->get('default_charset', RCMAIL_CHARSET);
50        }
51    }
52
53
54    /**
55     * Parse the given raw message source and return a structure
56     * of rcube_message_part objects.
57     *
58     * It makes use of the PEAR:Mail_mimeDecode library
59     *
60     * @param string  The message source
61     * @return object rcube_message_part The message structure
62     */
63    public static function parse_message($raw_body)
64    {
65        $mime = new Mail_mimeDecode($raw_body);
66        $struct = $mime->decode(array('include_bodies' => true, 'decode_bodies' => true));
67        return self::structure_part($struct);
68    }
69
70
71    /**
72     * Recursive method to convert a Mail_mimeDecode part into a rcube_message_part object
73     *
74     * @param object  A message part struct
75     * @param int     Part count
76     * @param string  Parent MIME ID
77     *
78     * @return object rcube_message_part
79     */
80    private static function structure_part($part, $count=0, $parent='')
81    {
82        $struct = new rcube_message_part;
83        $struct->mime_id = $part->mime_id ? $part->mime_id : (empty($parent) ? (string)$count : "$parent.$count");
84        $struct->headers = $part->headers;
85        $struct->ctype_primary = $part->ctype_primary;
86        $struct->ctype_secondary = $part->ctype_secondary;
87        $struct->mimetype = $part->ctype_primary . '/' . $part->ctype_secondary;
88        $struct->ctype_parameters = $part->ctype_parameters;
89
90        if ($part->headers['content-transfer-encoding'])
91            $struct->encoding = $part->headers['content-transfer-encoding'];
92        if ($part->ctype_parameters['charset'])
93            $struct->charset = $part->ctype_parameters['charset'];
94
95        $part_charset = $struct->charset ? $struct->charset : self::$default_charset;
96
97        // determine filename
98        if (($filename = $part->d_parameters['filename']) || ($filename = $part->ctype_parameters['name'])) {
99            $struct->filename = rcube_mime::decode_mime_string($filename, $part_charset);
100        }
101
102        // copy part body and convert it to UTF-8 if necessary
103        $struct->body = $part->ctype_primary == 'text' || !$part->ctype_parameters['charset'] ? rcube_charset::convert($part->body, $part_charset) : $part->body;
104        $struct->size = strlen($part->body);
105        $struct->disposition = $part->disposition;
106
107        foreach ((array)$part->parts as $child_part) {
108            $struct->parts[] = self::structure_part($child_part, ++$count, $struct->mime_id);
109        }
110
111        return $struct;
112    }
113
114
115    /**
116     * Split an address list into a structured array list
117     *
118     * @param string  $input    Input string
119     * @param int     $max      List only this number of addresses
120     * @param boolean $decode   Decode address strings
121     * @param string  $fallback Fallback charset if none specified
122     *
123     * @return array  Indexed list of addresses
124     */
125    static function decode_address_list($input, $max = null, $decode = true, $fallback = null)
126    {
127        $a   = self::parse_address_list($input, $decode, $fallback);
128        $out = array();
129        $j   = 0;
130
131        // Special chars as defined by RFC 822 need to in quoted string (or escaped).
132        $special_chars = '[\(\)\<\>\\\.\[\]@,;:"]';
133
134        if (!is_array($a))
135            return $out;
136
137        foreach ($a as $val) {
138            $j++;
139            $address = trim($val['address']);
140            $name    = trim($val['name']);
141
142            if ($name && $address && $name != $address)
143                $string = sprintf('%s <%s>', preg_match("/$special_chars/", $name) ? '"'.addcslashes($name, '"').'"' : $name, $address);
144            else if ($address)
145                $string = $address;
146            else if ($name)
147                $string = $name;
148
149            $out[$j] = array(
150                'name'   => $name,
151                'mailto' => $address,
152                'string' => $string
153            );
154
155            if ($max && $j==$max)
156                break;
157        }
158
159        return $out;
160    }
161
162
163    /**
164     * Decode a message header value
165     *
166     * @param string  $input         Header value
167     * @param string  $fallback      Fallback charset if none specified
168     *
169     * @return string Decoded string
170     */
171    public static function decode_header($input, $fallback = null)
172    {
173        $str = self::decode_mime_string((string)$input, $fallback);
174
175        return $str;
176    }
177
178
179    /**
180     * Decode a mime-encoded string to internal charset
181     *
182     * @param string $input    Header value
183     * @param string $fallback Fallback charset if none specified
184     *
185     * @return string Decoded string
186     */
187    public static function decode_mime_string($input, $fallback = null)
188    {
189        $default_charset = !empty($fallback) ? $fallback : self::$default_charset;
190
191        // rfc: all line breaks or other characters not found
192        // in the Base64 Alphabet must be ignored by decoding software
193        // delete all blanks between MIME-lines, differently we can
194        // receive unnecessary blanks and broken utf-8 symbols
195        $input = preg_replace("/\?=\s+=\?/", '?==?', $input);
196
197        // encoded-word regexp
198        $re = '/=\?([^?]+)\?([BbQq])\?([^\n]*?)\?=/';
199
200        // Find all RFC2047's encoded words
201        if (preg_match_all($re, $input, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
202            // Initialize variables
203            $tmp   = array();
204            $out   = '';
205            $start = 0;
206
207            foreach ($matches as $idx => $m) {
208                $pos      = $m[0][1];
209                $charset  = $m[1][0];
210                $encoding = $m[2][0];
211                $text     = $m[3][0];
212                $length   = strlen($m[0][0]);
213
214                // Append everything that is before the text to be decoded
215                if ($start != $pos) {
216                    $substr = substr($input, $start, $pos-$start);
217                    $out   .= rcube_charset_convert($substr, $default_charset);
218                    $start  = $pos;
219                }
220                $start += $length;
221
222                // Per RFC2047, each string part "MUST represent an integral number
223                // of characters . A multi-octet character may not be split across
224                // adjacent encoded-words." However, some mailers break this, so we
225                // try to handle characters spanned across parts anyway by iterating
226                // through and aggregating sequential encoded parts with the same
227                // character set and encoding, then perform the decoding on the
228                // aggregation as a whole.
229
230                $tmp[] = $text;
231                if ($next_match = $matches[$idx+1]) {
232                    if ($next_match[0][1] == $start
233                        && $next_match[1][0] == $charset
234                        && $next_match[2][0] == $encoding
235                    ) {
236                        continue;
237                    }
238                }
239
240                $count = count($tmp);
241                $text  = '';
242
243                // Decode and join encoded-word's chunks
244                if ($encoding == 'B' || $encoding == 'b') {
245                    // base64 must be decoded a segment at a time
246                    for ($i=0; $i<$count; $i++)
247                        $text .= base64_decode($tmp[$i]);
248                }
249                else { //if ($encoding == 'Q' || $encoding == 'q') {
250                    // quoted printable can be combined and processed at once
251                    for ($i=0; $i<$count; $i++)
252                        $text .= $tmp[$i];
253
254                    $text = str_replace('_', ' ', $text);
255                    $text = quoted_printable_decode($text);
256                }
257
258                $out .= rcube_charset_convert($text, $charset);
259                $tmp = array();
260            }
261
262            // add the last part of the input string
263            if ($start != strlen($input)) {
264                $out .= rcube_charset_convert(substr($input, $start), $default_charset);
265            }
266
267            // return the results
268            return $out;
269        }
270
271        // no encoding information, use fallback
272        return rcube_charset_convert($input, $default_charset);
273    }
274
275
276    /**
277     * Decode a mime part
278     *
279     * @param string $input    Input string
280     * @param string $encoding Part encoding
281     * @return string Decoded string
282     */
283    public static function decode($input, $encoding = '7bit')
284    {
285        switch (strtolower($encoding)) {
286        case 'quoted-printable':
287            return quoted_printable_decode($input);
288        case 'base64':
289            return base64_decode($input);
290        case 'x-uuencode':
291        case 'x-uue':
292        case 'uue':
293        case 'uuencode':
294            return convert_uudecode($input);
295        case '7bit':
296        default:
297            return $input;
298        }
299    }
300
301
302    /**
303     * Split RFC822 header string into an associative array
304     * @access private
305     */
306    public static function parse_headers($headers)
307    {
308        $a_headers = array();
309        $headers = preg_replace('/\r?\n(\t| )+/', ' ', $headers);
310        $lines = explode("\n", $headers);
311        $c = count($lines);
312
313        for ($i=0; $i<$c; $i++) {
314            if ($p = strpos($lines[$i], ': ')) {
315                $field = strtolower(substr($lines[$i], 0, $p));
316                $value = trim(substr($lines[$i], $p+1));
317                if (!empty($value))
318                    $a_headers[$field] = $value;
319            }
320        }
321
322        return $a_headers;
323    }
324
325
326    /**
327     * @access private
328     */
329    private static function parse_address_list($str, $decode = true, $fallback = null)
330    {
331        // remove any newlines and carriage returns before
332        $str = preg_replace('/\r?\n(\s|\t)?/', ' ', $str);
333
334        // extract list items, remove comments
335        $str = self::explode_header_string(',;', $str, true);
336        $result = array();
337
338        // simplified regexp, supporting quoted local part
339        $email_rx = '(\S+|("\s*(?:[^"\f\n\r\t\v\b\s]+\s*)+"))@\S+';
340
341        foreach ($str as $key => $val) {
342            $name    = '';
343            $address = '';
344            $val     = trim($val);
345
346            if (preg_match('/(.*)<('.$email_rx.')>$/', $val, $m)) {
347                $address = $m[2];
348                $name    = trim($m[1]);
349            }
350            else if (preg_match('/^('.$email_rx.')$/', $val, $m)) {
351                $address = $m[1];
352                $name    = '';
353            }
354            else {
355                $name = $val;
356            }
357
358            // dequote and/or decode name
359            if ($name) {
360                if ($name[0] == '"' && $name[strlen($name)-1] == '"') {
361                    $name = substr($name, 1, -1);
362                    $name = stripslashes($name);
363                }
364                if ($decode) {
365                    $name = self::decode_header($name, $fallback);
366                }
367            }
368
369            if (!$address && $name) {
370                $address = $name;
371            }
372
373            if ($address) {
374                $result[$key] = array('name' => $name, 'address' => $address);
375            }
376        }
377
378        return $result;
379    }
380
381
382    /**
383     * Explodes header (e.g. address-list) string into array of strings
384     * using specified separator characters with proper handling
385     * of quoted-strings and comments (RFC2822)
386     *
387     * @param string $separator       String containing separator characters
388     * @param string $str             Header string
389     * @param bool   $remove_comments Enable to remove comments
390     *
391     * @return array Header items
392     */
393    public static function explode_header_string($separator, $str, $remove_comments = false)
394    {
395        $length  = strlen($str);
396        $result  = array();
397        $quoted  = false;
398        $comment = 0;
399        $out     = '';
400
401        for ($i=0; $i<$length; $i++) {
402            // we're inside a quoted string
403            if ($quoted) {
404                if ($str[$i] == '"') {
405                    $quoted = false;
406                }
407                else if ($str[$i] == "\\") {
408                    if ($comment <= 0) {
409                        $out .= "\\";
410                    }
411                    $i++;
412                }
413            }
414            // we are inside a comment string
415            else if ($comment > 0) {
416                if ($str[$i] == ')') {
417                    $comment--;
418                }
419                else if ($str[$i] == '(') {
420                    $comment++;
421                }
422                else if ($str[$i] == "\\") {
423                    $i++;
424                }
425                continue;
426            }
427            // separator, add to result array
428            else if (strpos($separator, $str[$i]) !== false) {
429                if ($out) {
430                    $result[] = $out;
431                }
432                $out = '';
433                continue;
434            }
435            // start of quoted string
436            else if ($str[$i] == '"') {
437                $quoted = true;
438            }
439            // start of comment
440            else if ($remove_comments && $str[$i] == '(') {
441                $comment++;
442            }
443
444            if ($comment <= 0) {
445                $out .= $str[$i];
446            }
447        }
448
449        if ($out && $comment <= 0) {
450            $result[] = $out;
451        }
452
453        return $result;
454    }
455
456
457    /**
458     * Interpret a format=flowed message body according to RFC 2646
459     *
460     * @param string  $text Raw body formatted as flowed text
461     *
462     * @return string Interpreted text with unwrapped lines and stuffed space removed
463     */
464    public static function unfold_flowed($text)
465    {
466        $text = preg_split('/\r?\n/', $text);
467        $last = -1;
468        $q_level = 0;
469
470        foreach ($text as $idx => $line) {
471            if ($line[0] == '>' && preg_match('/^(>+\s*)/', $line, $regs)) {
472                $q = strlen(str_replace(' ', '', $regs[0]));
473                $line = substr($line, strlen($regs[0]));
474
475                if ($q == $q_level && $line
476                    && isset($text[$last])
477                    && $text[$last][strlen($text[$last])-1] == ' '
478                ) {
479                    $text[$last] .= $line;
480                    unset($text[$idx]);
481                }
482                else {
483                    $last = $idx;
484                }
485            }
486            else {
487                $q = 0;
488                if ($line == '-- ') {
489                    $last = $idx;
490                }
491                else {
492                    // remove space-stuffing
493                    $line = preg_replace('/^\s/', '', $line);
494
495                    if (isset($text[$last]) && $line
496                        && $text[$last] != '-- '
497                        && $text[$last][strlen($text[$last])-1] == ' '
498                    ) {
499                        $text[$last] .= $line;
500                        unset($text[$idx]);
501                    }
502                    else {
503                        $text[$idx] = $line;
504                        $last = $idx;
505                    }
506                }
507            }
508            $q_level = $q;
509        }
510
511        return implode("\r\n", $text);
512    }
513
514
515    /**
516     * Wrap the given text to comply with RFC 2646
517     *
518     * @param string $text Text to wrap
519     * @param int $length Length
520     *
521     * @return string Wrapped text
522     */
523    public static function format_flowed($text, $length = 72)
524    {
525        $text = preg_split('/\r?\n/', $text);
526
527        foreach ($text as $idx => $line) {
528            if ($line != '-- ') {
529                if ($line[0] == '>' && preg_match('/^(>+)/', $line, $regs)) {
530                    $prefix = $regs[0];
531                    $level = strlen($prefix);
532                    $line  = rtrim(substr($line, $level));
533                    $line  = $prefix . rc_wordwrap($line, $length - $level - 2, " \r\n$prefix ");
534                }
535                else if ($line) {
536                    $line = rc_wordwrap(rtrim($line), $length - 2, " \r\n");
537                    // space-stuffing
538                    $line = preg_replace('/(^|\r\n)(From| |>)/', '\\1 \\2', $line);
539                }
540
541                $text[$idx] = $line;
542            }
543        }
544
545        return implode("\r\n", $text);
546    }
547
548}
Note: See TracBrowser for help on using the repository browser.