alecpl
2010-09-25 e019f2d0f2dc2fbfa345ab5d7ae85e67bfdd76b8
commit | author | age
8fa58e 1 <?php
T 2
3 /*
4  +-----------------------------------------------------------------------+
5  | program/include/rcube_message.php                                     |
6  |                                                                       |
e019f2 7  | This file is part of the Roundcube Webmail client                     |
A 8  | Copyright (C) 2008-2010, Roundcube Dev. - Switzerland                 |
8fa58e 9  | Licensed under the GNU GPL                                            |
T 10  |                                                                       |
11  | PURPOSE:                                                              |
12  |   Logical representation of a mail message with all its data          |
13  |   and related functions                                               |
14  +-----------------------------------------------------------------------+
15  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
16  +-----------------------------------------------------------------------+
17
638fb8 18  $Id$
8fa58e 19
T 20 */
21
22
23 /**
45f56c 24  * Logical representation of a mail message with all its data
T 25  * and related functions
8fa58e 26  *
T 27  * @package    Mail
28  * @author     Thomas Bruederli <roundcube@gmail.com>
29  */
30 class rcube_message
31 {
d311d8 32     private $app;
A 33     private $imap;
34     private $opt = array();
35     private $inline_parts = array();
36     private $parse_alternative = false;
8fa58e 37   
d311d8 38     public $uid = null;
A 39     public $headers;
40     public $structure;
41     public $parts = array();
42     public $mime_parts = array();
43     public $attachments = array();
44     public $subject = '';
45     public $sender = null;
46     public $is_safe = false;
8fa58e 47   
193fb4 48
d311d8 49     /**
A 50      * __construct
51      *
52      * Provide a uid, and parse message structure.
53      *
54      * @param string $uid The message UID.
55      *
56      * @uses rcmail::get_instance()
57      * @uses rcube_imap::decode_mime_string()
58      * @uses self::set_safe()
59      *
60      * @see self::$app, self::$imap, self::$opt, self::$structure
61      */
62     function __construct($uid)
8fa58e 63     {
d311d8 64         $this->app = rcmail::get_instance();
A 65         $this->imap = $this->app->imap;
64e3e8 66
d311d8 67         $this->uid = $uid;
A 68         $this->headers = $this->imap->get_headers($uid, NULL, true, true);
64e3e8 69
A 70         if (!$this->headers)
71             return;
8fa58e 72
d311d8 73         $this->subject = rcube_imap::decode_mime_string(
A 74             $this->headers->subject, $this->headers->charset);
75         list(, $this->sender) = each($this->imap->decode_address_list($this->headers->from));
64e3e8 76
d311d8 77         $this->set_safe((intval($_GET['_safe']) || $_SESSION['safe_messages'][$uid]));
A 78         $this->opt = array(
79             'safe' => $this->is_safe,
80             'prefer_html' => $this->app->config->get('prefer_html'),
81             'get_url' => rcmail_url('get', array(
82                 '_mbox' => $this->imap->get_mailbox_name(), '_uid' => $uid))
83         );
8fa58e 84
d311d8 85         if ($this->structure = $this->imap->get_structure($uid, $this->headers->body_structure)) {
A 86             $this->get_mime_numbers($this->structure);
87             $this->parse_structure($this->structure);
aa16b4 88         }
d311d8 89         else {
A 90             $this->body = $this->imap->get_body($uid);
91         }
92
93         // notify plugins and let them analyze this structured message object
94         $this->app->plugins->exec_hook('message_load', array('object' => $this));
95     }
96   
97   
98     /**
99      * Return a (decoded) message header
100      *
101      * @param string Header name
102      * @param bool   Don't mime-decode the value
103      * @return string Header value
104      */
105     public function get_header($name, $raw = false)
106     {
107         $value = $this->headers->$name;
108         return $raw ? $value : $this->imap->decode_header($value);
109     }
110
111   
112     /**
113      * Set is_safe var and session data
114      *
115      * @param bool enable/disable
116      */
117     public function set_safe($safe = true)
118     {
119         $this->is_safe = $safe;
120         $_SESSION['safe_messages'][$this->uid] = $this->is_safe;
121     }
122
123
124     /**
125      * Compose a valid URL for getting a message part
126      *
127      * @param string Part MIME-ID
128      * @return string URL or false if part does not exist
129      */
130     public function get_part_url($mime_id)
131     {
132         if ($this->mime_parts[$mime_id])
133             return $this->opt['get_url'] . '&_part=' . $mime_id;
aa16b4 134         else
d311d8 135             return false;
8fa58e 136     }
T 137
d311d8 138
A 139     /**
140      * Get content of a specific part of this message
141      *
142      * @param string Part MIME-ID
143      * @param resource File pointer to save the message part
144      * @return string Part content
145      */
146     public function get_part_content($mime_id, $fp=NULL)
147     {
148         if ($part = $this->mime_parts[$mime_id]) {
149             // stored in message structure (winmail/inline-uuencode)
150             if ($part->encoding == 'stream') {
151                 if ($fp) {
152                     fwrite($fp, $part->body);
153                 }
154                 return $fp ? true : $part->body;
155             }
156             // get from IMAP
157             return $this->imap->get_message_part($this->uid, $mime_id, $part, NULL, $fp);
158         } else
159             return null;
8fa58e 160     }
T 161
162
d311d8 163     /**
A 164      * Determine if the message contains a HTML part
165      *
166      * @return bool True if a HTML is available, False if not
167      */
168     function has_html_part()
169     {
170         // check all message parts
171         foreach ($this->parts as $pid => $part) {
172             $mimetype = strtolower($part->ctype_primary . '/' . $part->ctype_secondary);
173             if ($mimetype == 'text/html')
174                 return true;
175         }
176
177         return false;
178     }
179
180
181     /**
182      * Return the first HTML part of this message
183      *
184      * @return string HTML message part content
185      */
186     function first_html_part()
187     {
188         // check all message parts
189         foreach ($this->mime_parts as $mime_id => $part) {
190             $mimetype = strtolower($part->ctype_primary . '/' . $part->ctype_secondary);
191             if ($mimetype == 'text/html') {
192                 return $this->imap->get_message_part($this->uid, $mime_id, $part);
193             }
194         }
195     }
196
197
198     /**
199      * Return the first text part of this message
200      *
201      * @return string Plain text message/part content
202      */
203     function first_text_part()
204     {
205         // no message structure, return complete body
206         if (empty($this->parts))
207             return $this->body;
208
209         $out = null;
210
211         // check all message parts
212         foreach ($this->mime_parts as $mime_id => $part) {
213             $mimetype = $part->ctype_primary . '/' . $part->ctype_secondary;
214
215             if ($mimetype == 'text/plain') {
216                 $out = $this->imap->get_message_part($this->uid, $mime_id, $part);
99b8c1 217
d311d8 218                 // re-format format=flowed content
A 219                 if ($part->ctype_secondary == 'plain' && $part->ctype_parameters['format'] == 'flowed')
220                     $out = self::unfold_flowed($out);
221                 break;
222             }
223             else if ($mimetype == 'text/html') {
99b8c1 224                 $out = $this->imap->get_message_part($this->uid, $mime_id, $part);
d311d8 225
A 226                 // remove special chars encoding
227                 $trans = array_flip(get_html_translation_table(HTML_ENTITIES));
99b8c1 228                 $out = strtr($out, $trans);
d311d8 229
A 230                 // create instance of html2text class
99b8c1 231                 $txt = new html2text($out);
d311d8 232                 $out = $txt->get_text();
A 233             }
234         }
235
236         return $out;
237     }
238
239
240     /**
241      * Raad the message structure returend by the IMAP server
242      * and build flat lists of content parts and attachments
243      *
244      * @param object rcube_message_part Message structure node
245      * @param bool  True when called recursively
246      */
247     private function parse_structure($structure, $recursive = false)
248     {
249         // real content-type of message/rfc822 part
eef0da 250         if ($structure->mimetype == 'message/rfc822' && $structure->real_mimetype)
5ced9c 251             $mimetype = $structure->real_mimetype;
A 252         else
253             $mimetype = $structure->mimetype;
d311d8 254
A 255         // show message headers
256         if ($recursive && is_array($structure->headers) && isset($structure->headers['subject'])) {
257             $c = new stdClass;
258             $c->type = 'headers';
259             $c->headers = &$structure->headers;
260             $this->parts[] = $c;
261         }
5ced9c 262
A 263         // Allow plugins to handle message parts
264         $plugin = $this->app->plugins->exec_hook('message_part_structure',
265             array('object' => $this, 'structure' => $structure,
266                 'mimetype' => $mimetype, 'recursive' => $recursive));
267
268         if ($plugin['abort'])
269             return;
270
271         $structure = $plugin['structure'];
272         list($message_ctype_primary, $message_ctype_secondary) = explode('/', $plugin['mimetype']);
d311d8 273
A 274         // print body if message doesn't have multiple parts
275         if ($message_ctype_primary == 'text' && !$recursive) {
276             $structure->type = 'content';
277             $this->parts[] = &$structure;
278             
279             // Parse simple (plain text) message body
280             if ($message_ctype_secondary == 'plain')
281                 foreach ((array)$this->uu_decode($structure) as $uupart) {
282                     $this->mime_parts[$uupart->mime_id] = $uupart;
283                     $this->attachments[] = $uupart;
284                 }
285         }
286         // the same for pgp signed messages
287         else if ($mimetype == 'application/pgp' && !$recursive) {
288             $structure->type = 'content';
289             $this->parts[] = &$structure;
290         }
291         // message contains alternative parts
292         else if ($mimetype == 'multipart/alternative' && is_array($structure->parts)) {
293             // get html/plaintext parts
294             $plain_part = $html_part = $print_part = $related_part = null;
295
296             foreach ($structure->parts as $p => $sub_part) {
297                 $sub_mimetype = $sub_part->mimetype;
298         
299                 // check if sub part is
300                 if ($sub_mimetype == 'text/plain')
301                     $plain_part = $p;
302                 else if ($sub_mimetype == 'text/html')
303                     $html_part = $p;
304                 else if ($sub_mimetype == 'text/enriched')
305                     $enriched_part = $p;
306                 else if (in_array($sub_mimetype, array('multipart/related', 'multipart/mixed', 'multipart/alternative')))
307                     $related_part = $p;
308             }
309
310             // parse related part (alternative part could be in here)
311             if ($related_part !== null && !$this->parse_alternative) {
312                 $this->parse_alternative = true;
313                 $this->parse_structure($structure->parts[$related_part], true);
314                 $this->parse_alternative = false;
315         
316                 // if plain part was found, we should unset it if html is preferred
317                 if ($this->opt['prefer_html'] && count($this->parts))
318                     $plain_part = null;
319             }
320
321             // choose html/plain part to print
322             if ($html_part !== null && $this->opt['prefer_html']) {
323                 $print_part = &$structure->parts[$html_part];
324             }
325             else if ($enriched_part !== null) {
326                 $print_part = &$structure->parts[$enriched_part];
327             }
328             else if ($plain_part !== null) {
329                 $print_part = &$structure->parts[$plain_part];
330             }
331
332             // add the right message body
333             if (is_object($print_part)) {
334                 $print_part->type = 'content';
335                 $this->parts[] = $print_part;
336             }
337             // show plaintext warning
338             else if ($html_part !== null && empty($this->parts)) {
339                 $c = new stdClass;
340                 $c->type            = 'content';
341                 $c->ctype_primary   = 'text';
342                 $c->ctype_secondary = 'plain';
343                 $c->body            = rcube_label('htmlmessage');
344
345                 $this->parts[] = $c;
346             }
347
348             // add html part as attachment
349             if ($html_part !== null && $structure->parts[$html_part] !== $print_part) {
350                 $html_part = &$structure->parts[$html_part];
351                 $html_part->filename = rcube_label('htmlmessage');
352                 $html_part->mimetype = 'text/html';
353
354                 $this->attachments[] = $html_part;
355             }
356         }
357         // this is an ecrypted message -> create a plaintext body with the according message
358         else if ($mimetype == 'multipart/encrypted') {
359             $p = new stdClass;
360             $p->type            = 'content';
361             $p->ctype_primary   = 'text';
362             $p->ctype_secondary = 'plain';
363             $p->body            = rcube_label('encryptedmessage');
364             $p->size            = strlen($p->body);
365         }
366         // message contains multiple parts
367         else if (is_array($structure->parts) && !empty($structure->parts)) {
368             // iterate over parts
369             for ($i=0; $i < count($structure->parts); $i++) {
370                 $mail_part      = &$structure->parts[$i];
371                 $primary_type   = $mail_part->ctype_primary;
372                 $secondary_type = $mail_part->ctype_secondary;
8fa58e 373
d311d8 374                 // real content-type of message/rfc822
A 375                 if ($mail_part->real_mimetype) {
376                     $part_orig_mimetype = $mail_part->mimetype;
377                     $part_mimetype = $mail_part->real_mimetype;
378                     list($primary_type, $secondary_type) = explode('/', $part_mimetype);
379                 }
380                 else
381                     $part_mimetype = $mail_part->mimetype;
6b6f2e 382
d311d8 383                 // multipart/alternative
A 384                 if ($primary_type == 'multipart') {
385                     $this->parse_structure($mail_part, true);
386
387                     // list message/rfc822 as attachment as well (mostly .eml)
388                     if ($part_orig_mimetype == 'message/rfc822' && !empty($mail_part->filename))
389                         $this->attachments[] = $mail_part;
390                 }
391                 // part text/[plain|html] OR message/delivery-status
392                 else if ((($part_mimetype == 'text/plain' || $part_mimetype == 'text/html') && $mail_part->disposition != 'attachment') ||
393                     $part_mimetype == 'message/delivery-status' || $part_mimetype == 'message/disposition-notification'
394                 ) {
1a2f83 395                     // Allow plugins to handle also this part
A 396                     $plugin = $this->app->plugins->exec_hook('message_part_structure',
397                         array('object' => $this, 'structure' => $mail_part,
398                             'mimetype' => $part_mimetype, 'recursive' => true));
399
400                     if ($plugin['abort'])
401                         continue;
402
403                     $mail_part = $plugin['structure'];
404                     list($primary_type, $secondary_type) = explode('/', $plugin['mimetype']);
405
d311d8 406                     // add text part if it matches the prefs
A 407                     if (!$this->parse_alternative ||
408                         ($secondary_type == 'html' && $this->opt['prefer_html']) ||
409                         ($secondary_type == 'plain' && !$this->opt['prefer_html'])
410                     ) {
411                         $mail_part->type = 'content';
412                         $this->parts[] = $mail_part;
413                     }
414           
415                     // list as attachment as well
416                     if (!empty($mail_part->filename))
417                         $this->attachments[] = $mail_part;
418                 }
419                 // part message/*
420                 else if ($primary_type=='message') {
421                     $this->parse_structure($mail_part, true);
422
423                     // list as attachment as well (mostly .eml)
424                     if (!empty($mail_part->filename))
425                         $this->attachments[] = $mail_part;
426                 }
427                 // ignore "virtual" protocol parts
428                 else if ($primary_type == 'protocol') {
429                     continue;
430                 }
431                 // part is Microsoft Outlook TNEF (winmail.dat)
432                 else if ($part_mimetype == 'application/ms-tnef') {
433                     foreach ((array)$this->tnef_decode($mail_part) as $tpart) {
434                         $this->mime_parts[$tpart->mime_id] = $tpart;
435                         $this->attachments[] = $tpart;
436                     }
437                 }
438                 // part is a file/attachment
439                 else if (preg_match('/^(inline|attach)/', $mail_part->disposition) ||
440                     $mail_part->headers['content-id'] || (empty($mail_part->disposition) && $mail_part->filename)
441                 ) {
442                     // skip apple resource forks
443                     if ($message_ctype_secondary == 'appledouble' && $secondary_type == 'applefile')
444                         continue;
445
446                     // part belongs to a related message and is linked
447                     if ($mimetype == 'multipart/related'
448                         && ($mail_part->headers['content-id'] || $mail_part->headers['content-location'])) {
449                         if ($mail_part->headers['content-id'])
450                             $mail_part->content_id = preg_replace(array('/^</', '/>$/'), '', $mail_part->headers['content-id']);
451                         if ($mail_part->headers['content-location'])
452                             $mail_part->content_location = $mail_part->headers['content-base'] . $mail_part->headers['content-location'];
453
454                         $this->inline_parts[] = $mail_part;
455                     }
456                     // attachment encapsulated within message/rfc822 part needs further decoding (#1486743)
457                     else if ($part_orig_mimetype == 'message/rfc822') {
458                         $this->parse_structure($mail_part, true);
459                     }
460                     // is a regular attachment
461                     else if (preg_match('!^[a-z0-9-.+]+/[a-z0-9-.+]+$!i', $part_mimetype)) {
462                         if (!$mail_part->filename)
463                             $mail_part->filename = 'Part '.$mail_part->mime_id;
464                         $this->attachments[] = $mail_part;
465                     }
466                 }
467             }
468
469             // if this was a related part try to resolve references
470             if ($mimetype == 'multipart/related' && sizeof($this->inline_parts)) {
471                 $a_replaces = array();
472
473                 foreach ($this->inline_parts as $inline_object) {
474                     $part_url = $this->get_part_url($inline_object->mime_id);
475                     if ($inline_object->content_id)
476                         $a_replaces['cid:'.$inline_object->content_id] = $part_url;
477                     if ($inline_object->content_location)
478                         $a_replaces[$inline_object->content_location] = $part_url;
479                 }
480
481                 // add replace array to each content part
482                 // (will be applied later when part body is available)
483                 foreach ($this->parts as $i => $part) {
484                     if ($part->type == 'content')
485                         $this->parts[$i]->replaces = $a_replaces;
486                 }
487             }
488         }
489         // message is a single part non-text
490         else if ($structure->filename) {
491             $this->attachments[] = $structure;
492         }
3e58bf 493         // message is a single part non-text (without filename)
A 494         else if (preg_match('/application\//i', $mimetype)) {
495             $structure->filename = 'Part '.$structure->mime_id;
496             $this->attachments[] = $structure;
497         }
6b6f2e 498     }
d311d8 499
A 500
501     /**
502      * Fill aflat array with references to all parts, indexed by part numbers
503      *
504      * @param object rcube_message_part Message body structure
505      */
506     private function get_mime_numbers(&$part)
507     {
508         if (strlen($part->mime_id))
509             $this->mime_parts[$part->mime_id] = &$part;
f19d86 510
d311d8 511         if (is_array($part->parts))
A 512             for ($i=0; $i<count($part->parts); $i++)
513                 $this->get_mime_numbers($part->parts[$i]);
514     }
515
516
517     /**
518      * Decode a Microsoft Outlook TNEF part (winmail.dat)
519      *
520      * @param object rcube_message_part Message part to decode
521      */
522     function tnef_decode(&$part)
523     {
524         // @TODO: attachment may be huge, hadle it via file
525         if (!isset($part->body))
526             $part->body = $this->imap->get_message_part($this->uid, $part->mime_id, $part);
527
528         $parts = array();
f19d86 529         $tnef = new tnef_decoder;
A 530         $tnef_arr = $tnef->decompress($part->body);
d311d8 531
A 532         foreach ($tnef_arr as $pid => $winatt) {
533             $tpart = new rcube_message_part;
534
535             $tpart->filename        = trim($winatt['name']);
536             $tpart->encoding        = 'stream';
f19d86 537             $tpart->ctype_primary   = trim(strtolower($winatt['type']));
A 538             $tpart->ctype_secondary = trim(strtolower($winatt['subtype']));
d311d8 539             $tpart->mimetype        = $tpart->ctype_primary . '/' . $tpart->ctype_secondary;
A 540             $tpart->mime_id         = 'winmail.' . $part->mime_id . '.' . $pid;
541             $tpart->size            = $winatt['size'];
542             $tpart->body            = $winatt['stream'];
543
544             $parts[] = $tpart;
545             unset($tnef_arr[$pid]);
546         }
f19d86 547
d311d8 548         return $parts;
A 549     }
550
551
552     /**
553      * Parse message body for UUencoded attachments bodies
554      *
555      * @param object rcube_message_part Message part to decode
556      */
557     function uu_decode(&$part)
558     {
559         // @TODO: messages may be huge, hadle body via file
560         if (!isset($part->body))
561             $part->body = $this->imap->get_message_part($this->uid, $part->mime_id, $part);
562
563         $parts = array();
564         // FIXME: line length is max.65?
565         $uu_regexp = '/begin [0-7]{3,4} ([^\n]+)\n(([\x21-\x7E]{0,65}\n)+)`\nend/s';
566
567         if (preg_match_all($uu_regexp, $part->body, $matches, PREG_SET_ORDER)) {
568             // remove attachments bodies from the message body
569             $part->body = preg_replace($uu_regexp, '', $part->body);
570             // update message content-type
571             $part->ctype_primary   = 'multipart';
572             $part->ctype_secondary = 'mixed';
573             $part->mimetype        = $part->ctype_primary . '/' . $part->ctype_secondary;
574
575             // add attachments to the structure
576             foreach ($matches as $pid => $att) {
577                 $uupart = new rcube_message_part;
578
579                 $uupart->filename = trim($att[1]);
580                 $uupart->encoding = 'stream';
581                 $uupart->body     = convert_uudecode($att[2]);
582                 $uupart->size     = strlen($uupart->body);
583                 $uupart->mime_id  = 'uu.' . $part->mime_id . '.' . $pid;
584
585                 $ctype = rc_mime_content_type($uupart->body, $uupart->filename, 'application/octet-stream', true);
586                 $uupart->mimetype = $ctype;
587                 list($uupart->ctype_primary, $uupart->ctype_secondary) = explode('/', $ctype);
588
589                 $parts[] = $uupart;
590                 unset($matches[$pid]);
591             }
592         }
f19d86 593
d311d8 594         return $parts;
A 595     }
596
597
598     /**
599      * Interpret a format=flowed message body according to RFC 2646
600      *
601      * @param string  Raw body formatted as flowed text
602      * @return string Interpreted text with unwrapped lines and stuffed space removed
603      */
604     public static function unfold_flowed($text)
605     {
99b8c1 606         $text = preg_split('/\r?\n/', $text);
A 607         $last = -1;
608         $q_level = 0;
609
610         foreach ($text as $idx => $line) {
611             if ($line[0] == '>' && preg_match('/^(>+\s*)/', $line, $regs)) {
612                 $q = strlen(str_replace(' ', '', $regs[0]));
613                 $line = substr($line, strlen($regs[0]));
614
f12d21 615                 if ($q == $q_level && $line
A 616                     && isset($text[$last])
99b8c1 617                     && $text[$last][strlen($text[$last])-1] == ' '
A 618                 ) {
619                     $text[$last] .= $line;
620                     unset($text[$idx]);
621                 }
622                 else {
623                     $last = $idx;
624                 }
625             }
626             else {
627                 $q = 0;
628                 if ($line == '-- ') {
629                     $last = $idx;
630                 }
631                 else {
632                     // remove space-stuffing
633                     $line = preg_replace('/^\s/', '', $line);
634
f12d21 635                     if (isset($text[$last]) && $line
99b8c1 636                         && $text[$last] != '-- '
A 637                         && $text[$last][strlen($text[$last])-1] == ' '
638                     ) {
639                         $text[$last] .= $line;
640                         unset($text[$idx]);
641                     }
642                     else {
643                         $text[$idx] = $line;
644                         $last = $idx;
645                     }
646                 }
647             }
648             $q_level = $q;
649         }
650
651         return implode("\r\n", $text);
d311d8 652     }
A 653
654
655     /**
656      * Wrap the given text to comply with RFC 2646
657      */
658     public static function format_flowed($text, $length = 72)
659     {
99b8c1 660         $text = preg_split('/\r?\n/', $text);
d311d8 661
99b8c1 662         foreach ($text as $idx => $line) {
A 663             if ($line != '-- ') {
664                 if ($line[0] == '>' && preg_match('/^(>+)/', $line, $regs)) {
665                     $prefix = $regs[0];
666                     $level = strlen($prefix);
667                     $line  = rtrim(substr($line, $level));
668                     $line  = $prefix . rc_wordwrap($line, $length - $level - 2, " \r\n$prefix ");
669                 }
670                 else {
671                     $line = ' ' . rc_wordwrap(rtrim($line), $length - 2, " \r\n ");
672                 }
673
674                 $text[$idx] = $line;
675             }
d311d8 676         }
99b8c1 677
A 678         return implode("\r\n", $text);
d311d8 679     }
6b6f2e 680
8fa58e 681 }