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