0012024: remember popup window size in client state
[tine20] / tine20 / Tinebase / Mail.php
1 <?php
2 /**
3  * Tine 2.0
4  *
5  * @package     Tinebase
6  * @subpackage  Mail
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @copyright   Copyright (c) 2008-2014 Metaways Infosystems GmbH (http://www.metaways.de)
9  * @author      Lars Kneschke <l.kneschke@metaways.de>
10  */
11
12 /**
13  * This class extends the Zend_Mail class 
14  *
15  * @package     Tinebase
16  * @subpackage  Mail
17  */
18 class Tinebase_Mail extends Zend_Mail
19 {
20     /**
21     * email address regexp
22     */
23     const EMAIL_ADDRESS_REGEXP = '/([a-z0-9_\+-\.]+@[a-z0-9-\.]+\.[a-z]{2,63})/i';
24     
25     /**
26      * Sender: address
27      * @var string
28      */
29     protected $_sender = null;
30     
31     /**
32      * fallback charset constant
33      * 
34      * @var string
35      */
36     const DEFAULT_FALLBACK_CHARSET = 'iso-8859-15';
37     
38     /**
39      * create Tinebase_Mail from Zend_Mail_Message
40      * 
41      * @param  Zend_Mail_Message  $_zmm
42      * @param  string             $_replyBody
43      * @return Tinebase_Mail
44      */
45     public static function createFromZMM(Zend_Mail_Message $_zmm, $_replyBody = null)
46     {
47         $contentStream = fopen("php://temp", 'r+');
48         fputs($contentStream, $_zmm->getContent());
49         rewind($contentStream);
50         
51         $mp = new Zend_Mime_Part($contentStream);
52         self::_getMetaDataFromZMM($_zmm, $mp);
53         
54         // append old body when no multipart/mixed
55         if ($_replyBody !== null && $_zmm->headerExists('content-transfer-encoding')) {
56             $mp = self::_appendReplyBody($mp, $_replyBody);
57         } else {
58             $mp->decodeContent();
59             if ($_zmm->headerExists('content-transfer-encoding')) {
60                 switch ($_zmm->getHeader('content-transfer-encoding')) {
61                     case Zend_Mime::ENCODING_BASE64:
62                         // BASE64 encode has a bug that swallows the last char(s)
63                         $bodyEncoding = Zend_Mime::ENCODING_7BIT;
64                         break;
65                     default: 
66                         $bodyEncoding = $_zmm->getHeader('content-transfer-encoding');
67                 }
68             } else {
69                 $bodyEncoding = Zend_Mime::ENCODING_7BIT;
70             }
71             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
72                 . ' Using encoding: ' . $bodyEncoding);
73             $mp->encoding = $bodyEncoding;
74         }
75         
76         $result = new Tinebase_Mail('utf-8');
77         $result->setBodyText($mp);
78         $result->setHeadersFromZMM($_zmm);
79         
80         return $result;
81     }
82     
83     /**
84      * get meta data (like contentype, charset, ...) from zmm and set it in zmp
85      * 
86      * @param Zend_Mail_Message $zmm
87      * @param Zend_Mime_Part $zmp
88      */
89     protected static function _getMetaDataFromZMM(Zend_Mail_Message $zmm, Zend_Mime_Part $zmp)
90     {
91         if ($zmm->headerExists('content-transfer-encoding')) {
92             $zmp->encoding = $zmm->getHeader('content-transfer-encoding');
93         } else {
94             $zmp->encoding = Zend_Mime::ENCODING_7BIT;
95         }
96         
97         if ($zmm->headerExists('content-type')) {
98             $contentTypeHeader = Zend_Mime_Decode::splitHeaderField($zmm->getHeader('content-type'));
99             
100             $zmp->type = $contentTypeHeader[0];
101             
102             if (isset($contentTypeHeader['boundary'])) {
103                 $zmp->boundary = $contentTypeHeader['boundary'];
104             }
105             
106             if (isset($contentTypeHeader['charset'])) {
107                 $zmp->charset = $contentTypeHeader['charset'];
108             }
109         } else {
110             $zmp->type = Zend_Mime::TYPE_TEXT;
111         }
112         
113         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
114             . ' Encoding: ' . $zmp->encoding . ' / type: ' . $zmp->type . ' / charset: ' . $zmp->charset);
115     }
116     
117     /**
118      * appends old body to mime part
119      * 
120      * @param Zend_Mime_Part $mp
121      * @param string $replyBody plain/text reply body
122      * @return Zend_Mime_Part
123      */
124     protected static function _appendReplyBody(Zend_Mime_Part $mp, $replyBody)
125     {
126         $decodedContent = Tinebase_Mail::getDecodedContent($mp, NULL, FALSE);
127         $type = $mp->type;
128         
129         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) {
130             Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . " mp content: " . $decodedContent);
131             Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . " reply body: " . $replyBody);
132         }
133         
134         if ($type === Zend_Mime::TYPE_HTML && /* checks if $replyBody does not contains tags */ $replyBody === strip_tags($replyBody)) {
135             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
136                 . " Converting plain/text reply body to HTML");
137             $replyBody = self::convertFromTextToHTML($replyBody);
138         }
139         
140         if ($type === Zend_Mime::TYPE_HTML && preg_match('/(<\/body>[\s\r\n]*<\/html>)/i', $decodedContent, $matches)) {
141             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
142                 . ' Appending reply body to html body.');
143             
144             $decodedContent = str_replace($matches[1], $replyBody . $matches[1], $decodedContent);
145         } else {
146             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
147                 . " Appending reply body to mime text part.");
148             
149             $decodedContent .= $replyBody;
150         }
151         
152         $mp = new Zend_Mime_Part($decodedContent);
153         $mp->charset = 'utf-8';
154         $mp->type = $type;
155         
156         return $mp;
157     }
158     
159     /**
160      * Sets the HTML body for the message
161      *
162      * @param  string|Zend_Mime_Part    $html
163      * @param  string    $charset
164      *  @param  string    $encoding
165      * @return Zend_Mail Provides fluent interface
166      */
167     public function setBodyHtml($html, $charset = null, $encoding = Zend_Mime::ENCODING_QUOTEDPRINTABLE)
168     {
169         if ($html instanceof Zend_Mime_Part) {
170             $mp = $html;
171         } else {
172             if ($charset === null) {
173                 $charset = $this->_charset;
174             }
175         
176             $mp = new Zend_Mime_Part($html);
177             $mp->encoding = $encoding;
178             $mp->type = Zend_Mime::TYPE_HTML;
179             $mp->disposition = Zend_Mime::DISPOSITION_INLINE;
180             $mp->charset = $charset;
181         }
182         
183         $this->_bodyHtml = $mp;
184     
185         return $this;
186     }
187     
188     /**
189      * Sets the text body for the message.
190      *
191      * @param  string|Zend_Mime_Part $txt
192      * @param  string $charset
193      * @param  string $encoding
194      * @return Zend_Mail Provides fluent interface
195     */
196     public function setBodyText($txt, $charset = null, $encoding = Zend_Mime::ENCODING_QUOTEDPRINTABLE)
197     {
198         if ($txt instanceof Zend_Mime_Part) {
199             $mp = $txt;
200         } else {
201             if ($charset === null) {
202                 $charset = $this->_charset;
203             }
204     
205             $mp = new Zend_Mime_Part($txt);
206             $mp->encoding = $encoding;
207             $mp->type = Zend_Mime::TYPE_TEXT;
208             $mp->disposition = Zend_Mime::DISPOSITION_INLINE;
209             $mp->charset = $charset;
210         }
211         
212         $this->_bodyText = $mp;
213
214         return $this;
215     }
216
217     public function setBodyPGPMime($amored)
218     {
219         $this->_type = 'multipart/encrypted; protocol="application/pgp-encrypted"';
220
221         // PGP/MIME Versions Identification
222         $pgpIdent = new Zend_Mime_Part('Version: 1');
223         $pgpIdent->encoding = '7bit';
224         $pgpIdent->type = 'application/pgp-encrypted';
225         $pgpIdent->description = 'PGP/MIME Versions Identification';
226         $this->_bodyText = $pgpIdent;
227
228         // OpenPGP encrypted message
229         $pgpMessage = new Zend_Mime_Part($amored);
230         $pgpMessage->encoding = '7bit';
231         $pgpMessage->disposition = 'inline; filename=encrypted.asc';
232         $pgpMessage->type = 'application/octet-stream; name=encrypted.asc';
233         $pgpMessage->description = 'OpenPGP encrypted message';
234         $this->_bodyHtml = $pgpMessage;
235     }
236
237     /**
238      * set headers
239      * 
240      * @param Zend_Mail_Message $_zmm
241      * @return Zend_Mail Provides fluent interface
242      */
243     public function setHeadersFromZMM(Zend_Mail_Message $_zmm)
244     {
245         foreach ($_zmm->getHeaders() as $header => $values) {
246             foreach ((array)$values as $value) {
247                 switch ($header) {
248                     case 'content-transfer-encoding':
249                     // these are implicitly set by Zend_Mail_Transport_Abstract::_getHeaders()
250                     case 'content-type':
251                     case 'mime-version':
252                         // do nothing
253                         break;
254                         
255                     case 'bcc':
256                         $addresses = self::parseAdresslist($value);
257                         foreach ($addresses as $address) {
258                             $this->addBcc($address['address'], $address['name']);
259                         }
260                         break;
261                         
262                     case 'cc':
263                         $addresses = self::parseAdresslist($value);
264                         foreach ($addresses as $address) {
265                             $this->addCc($address['address'], $address['name']);
266                         }
267                         break;
268                         
269                     case 'date':
270                         try {
271                             $this->setDate($value);
272                         } catch (Zend_Mail_Exception $zme) {
273                             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE))
274                                 Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . " Could not set date: " . $value);
275                             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE))
276                                 Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . " " . $zme);
277                             $this->setDate();
278                         }
279                         break;
280                         
281                     case 'from':
282                         $addresses = self::parseAdresslist($value);
283                         foreach ($addresses as $address) {
284                             $this->setFrom($address['address'], $address['name']);
285                         }
286                         break;
287                         
288                     case 'message-id':
289                         $this->setMessageId($value);
290                         break;
291                         
292                     case 'return-path':
293                         $this->setReturnPath($value);
294                         break;
295                         
296                     case 'subject':
297                         $this->setSubject($value);
298                         break;
299                         
300                     case 'to':
301                         $addresses = self::parseAdresslist($value);
302                         foreach ($addresses as $address) {
303                             $this->addTo($address['address'], $address['name']);
304                         }
305                         break;
306                         
307                     default:
308                         $this->addHeader($header, $value);
309                         break;
310                 }
311             }
312         }
313         
314         return $this;
315     }
316
317     /**
318      * Sets Sender-header and sender of the message
319      *
320      * @param  string    $email
321      * @param  string    $name
322      * @return Zend_Mail Provides fluent interface
323      * @throws Zend_Mail_Exception if called subsequent times
324      */
325     public function setSender($email, $name = '')
326     {
327         if ($this->_sender === null) {
328             $email = strtr($email,"\r\n\t",'???');
329             $this->_from = $email;
330             $this->_storeHeader('Sender', $this->_encodeHeader('"'.$name.'"').' <'.$email.'>', true);
331         } else {
332             throw new Zend_Mail_Exception('Sender Header set twice');
333         }
334         return $this;
335     }
336     
337     /**
338      * Formats e-mail address
339      * 
340      * NOTE: we always add quotes to the name as this caused problems when name is encoded
341      * @see Zend_Mail::_formatAddress
342      *
343      * @param string $email
344      * @param string $name
345      * @return string
346      */
347     protected function _formatAddress($email, $name)
348     {
349         if ($name === '' || $name === null || $name === $email) {
350             return $email;
351         } else {
352             $encodedName = $this->_encodeHeader($name);
353             $format = '"%s" <%s>';
354             return sprintf($format, $encodedName, $email);
355         }
356     }
357
358     /**
359      * check if Zend_Mail_Message is/contains calendar iMIP message
360      * 
361      * @param Zend_Mail_Message $zmm
362      * @return boolean
363      */
364     public static function isiMIPMail(Zend_Mail_Message $zmm)
365     {
366         foreach ($zmm as $part) {
367             if (preg_match('/text\/calendar/', $part->contentType)) {
368                 return TRUE;
369             }
370         }
371         
372         return FALSE;
373     }
374     
375     /**
376      * get decoded body content
377      * 
378      * @param Zend_Mime_Part $zmp
379      * @param array $partStructure
380      * @param boolean $appendCharsetFilter
381      * @return string
382      */
383     public static function getDecodedContent(Zend_Mime_Part $zmp, $_partStructure = NULL, $appendCharsetFilter = TRUE)
384     {
385         $charset = self::_getCharset($zmp, $_partStructure);
386         if ($appendCharsetFilter) {
387             $charset = self::_appendCharsetFilter($zmp, $charset);
388         }
389         $encoding = ($_partStructure && ! empty($_partStructure['encoding'])) ? $_partStructure['encoding'] : $zmp->encoding;
390         
391         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
392             . " Trying to decode mime part content. Encoding/charset: " . $encoding . ' / ' . $charset);
393         
394         // need to set error handler because stream_get_contents just throws a E_WARNING
395         set_error_handler('Tinebase_Mail::decodingErrorHandler', E_WARNING);
396         try {
397             $body = $zmp->getDecodedContent();
398             restore_error_handler();
399             
400         } catch (Tinebase_Exception $e) {
401             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__
402                 . " Decoding of " . $zmp->encoding . '/' . $encoding . ' encoded message failed: ' . $e->getMessage());
403             
404             // trying to fix decoding problems
405             restore_error_handler();
406             $zmp->resetStream();
407             if (preg_match('/convert\.quoted-printable-decode/', $e->getMessage())) {
408                 if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' Trying workaround for http://bugs.php.net/50363.');
409                 $body = quoted_printable_decode(stream_get_contents($zmp->getRawStream()));
410                 $body = iconv($charset, 'utf-8', $body);
411             } else {
412                 if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' Try again with fallback encoding.');
413                 $zmp->appendDecodeFilter(self::_getDecodeFilter());
414                 set_error_handler('Tinebase_Mail::decodingErrorHandler', E_WARNING);
415                 try {
416                     $body = $zmp->getDecodedContent();
417                     restore_error_handler();
418                 } catch (Tinebase_Exception $e) {
419                     restore_error_handler();
420                     if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' Fallback encoding failed. Trying base64_decode().');
421                     $zmp->resetStream();
422                     $body = base64_decode(stream_get_contents($zmp->getRawStream()));
423                     $body = iconv($charset, 'utf-8', $body);
424                 }
425             }
426         }
427         
428         return $body;
429     }
430     /**
431      * convert charset (and return charset)
432      *
433      * @param  Zend_Mime_Part  $_part
434      * @param  array           $_structure
435      * @return string   
436      */
437     protected static function _getCharset(Zend_Mime_Part $_part, $_structure = NULL)
438     {
439         return ($_structure && isset($_structure['parameters']['charset'])) 
440             ? $_structure['parameters']['charset']
441             : ($_part->charset ? $_part->charset : self::DEFAULT_FALLBACK_CHARSET);
442     }
443     
444     /**
445      * convert charset (and return charset)
446      *
447      * @param  Zend_Mime_Part  $_part
448      * @param  string          $charset
449      * @return string   
450      */
451     protected static function _appendCharsetFilter(Zend_Mime_Part $_part, $charset)
452     {
453         if ($charset == 'utf8') {
454             $charset = 'utf-8';
455         } else if ($charset == 'us-ascii') {
456             // us-ascii caused problems with iconv encoding to utf-8
457             $charset = self::DEFAULT_FALLBACK_CHARSET;
458         } else if (strpos($charset, '.') !== false) {
459             // the stream filter does not like charsets with a dot in its name
460             // stream_filter_append(): unable to create or locate filter "convert.iconv.ansi_x3.4-1968/utf-8//IGNORE"
461             $charset = self::DEFAULT_FALLBACK_CHARSET;
462         } else if (iconv($charset, 'utf-8', '') === false) {
463             // check if charset is supported by iconv
464             $charset = self::DEFAULT_FALLBACK_CHARSET;
465         }
466         
467         $_part->appendDecodeFilter(self::_getDecodeFilter($charset));
468         
469         return $charset;
470     }
471     
472     /**
473      * get decode filter for stream_filter_append
474      * 
475      * @param string $_charset
476      * @return string
477      */
478     protected static function _getDecodeFilter($_charset = self::DEFAULT_FALLBACK_CHARSET)
479     {
480         if (in_array(strtolower($_charset), array('iso-8859-1', 'windows-1252', 'iso-8859-15')) && extension_loaded('mbstring')) {
481             require_once 'StreamFilter/ConvertMbstring.php';
482             $filter = 'convert.mbstring';
483         } else {
484             $filter = "convert.iconv.$_charset/utf-8//IGNORE";
485         }
486         
487         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' Appending decode filter: ' . $filter);
488         
489         return $filter;
490     }
491     
492     /**
493      * error exception handler for iconv decoding errors / only gets E_WARNINGs
494      *
495      * NOTE: PHP < 5.3 don't throws exceptions for Catchable fatal errors per default,
496      * so we convert them into exceptions manually
497      *
498      * @param integer $severity
499      * @param string $errstr
500      * @param string $errfile
501      * @param integer $errline
502      * @throws Tinebase_Exception
503      * 
504      * @todo maybe we can remove that because php 5.3+ is required now
505      */
506     public static function decodingErrorHandler($severity, $errstr, $errfile, $errline)
507     {
508         Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . " $errstr in {$errfile}::{$errline} ($severity)");
509         
510         throw new Tinebase_Exception($errstr);
511     }
512     
513     /**
514      * parse address list
515      *
516      * @param string $_adressList
517      * @return array
518      */
519     public static function parseAdresslist($_addressList)
520     {
521         if (strpos($_addressList, ',') !== FALSE && substr_count($_addressList, '@') == 1) {
522             // we have a comma in the name -> do not split string!
523             $addresses = array($_addressList);
524         } else {
525             // create stream to be used with fgetcsv
526             $stream = fopen("php://temp", 'r+');
527             fputs($stream, $_addressList);
528             rewind($stream);
529             
530             // alternative solution to create stream; yet untested
531             #$stream = fopen('data://text/plain;base64,' . base64_encode($_addressList), 'r');
532             
533             // split addresses
534             $addresses = fgetcsv($stream);
535         }
536         
537         if (! is_array($addresses)) {
538             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . 
539                 ' Could not parse addresses: ' . var_export($addresses, TRUE));
540             return array();
541         }
542         
543         foreach ($addresses as $key => $address) {
544             if (preg_match('/(.*)<(.+@[^@]+)>/', $address, $matches)) {
545                 $name = trim(trim($matches[1]), '"');
546                 $address = trim($matches[2]);
547                 $addresses[$key] = array('name' => substr($name, 0, 250), 'address' => $address);
548             } else {
549                 $address = preg_replace('/[,;]*/i', '', $address);
550                 $addresses[$key] = array('name' => null, 'address' => trim($address));
551             }
552         }
553
554         return $addresses;
555     }
556
557     /**
558      * convert text to html
559      * - replace quotes ('>  ') with blockquotes 
560      * - does htmlspecialchars()
561      * - converts linebreaks to <br />
562      * 
563      * @param string $text
564      * @param string $blockquoteClass
565      * @return string
566      */
567     public static function convertFromTextToHTML($text, $blockquoteClass = null)
568     {
569         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Input: ' . $text);
570         
571         $lines = preg_split('/\r\n|\n|\r/', $text);
572         $result = array();
573         $indention = 0;
574         foreach ($lines as $line) {
575             // get indention level and remove quotes
576             if (preg_match('/^>[> ]*/', $line, $matches)) {
577                 $indentionLevel = substr_count($matches[0], '>');
578                 $line = str_replace($matches[0], '', $line);
579             } else {
580                 $indentionLevel = 0;
581             }
582             
583             // convert html special chars
584             $line = htmlspecialchars($line, ENT_COMPAT, 'UTF-8');
585             
586             // set blockquote tags for current indentionLevel
587             while ($indention < $indentionLevel) {
588                 $class = $blockquoteClass ? 'class="' . $blockquoteClass . '"' : '';
589                 $line = '<blockquote ' . $class . '>' . $line;
590                 $indention++;
591             }
592             while ($indention > $indentionLevel) {
593                 $line = '</blockquote>' . $line;
594                 $indention--;
595             }
596             
597             $result[] = $line;
598             
599             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Line: ' . $line);
600         }
601         
602         $result = implode('<br />', $result);
603         
604         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Result: ' . $result);
605         
606         return $result;
607     }
608 }