e6d1af486b738438c557b6d8a1145ce1fbcdafb9
[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     public function setBodyPGPMime($amored)
223     {
224         $this->_type = 'multipart/encrypted; protocol="application/pgp-encrypted"';
225
226         // PGP/MIME Versions Identification
227         $pgpIdent = new Zend_Mime_Part('Version: 1');
228         $pgpIdent->encoding = '7bit';
229         $pgpIdent->type = 'application/pgp-encrypted';
230         $pgpIdent->description = 'PGP/MIME Versions Identification';
231         $this->_bodyText = $pgpIdent;
232
233         // OpenPGP encrypted message
234         $pgpMessage = new Zend_Mime_Part($amored);
235         $pgpMessage->encoding = '7bit';
236         $pgpMessage->disposition = 'inline; filename=encrypted.asc';
237         $pgpMessage->type = 'application/octet-stream; name=encrypted.asc';
238         $pgpMessage->description = 'OpenPGP encrypted message';
239         $this->_bodyHtml = $pgpMessage;
240     }
241
242     /**
243      * set headers
244      * 
245      * @param Zend_Mail_Message $_zmm
246      * @return Zend_Mail Provides fluent interface
247      */
248     public function setHeadersFromZMM(Zend_Mail_Message $_zmm)
249     {
250         foreach ($_zmm->getHeaders() as $header => $values) {
251             foreach ((array)$values as $value) {
252                 switch ($header) {
253                     case 'content-transfer-encoding':
254                     // these are implicitly set by Zend_Mail_Transport_Abstract::_getHeaders()
255                     case 'content-type':
256                     case 'mime-version':
257                         // do nothing
258                         break;
259                         
260                     case 'bcc':
261                         $addresses = self::parseAdresslist($value);
262                         foreach ($addresses as $address) {
263                             $this->addBcc($address['address'], $address['name']);
264                         }
265                         break;
266                         
267                     case 'cc':
268                         $addresses = self::parseAdresslist($value);
269                         foreach ($addresses as $address) {
270                             $this->addCc($address['address'], $address['name']);
271                         }
272                         break;
273                         
274                     case 'date':
275                         try {
276                             $this->setDate($value);
277                         } catch (Zend_Mail_Exception $zme) {
278                             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE))
279                                 Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . " Could not set date: " . $value);
280                             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE))
281                                 Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . " " . $zme);
282                             $this->setDate();
283                         }
284                         break;
285                         
286                     case 'from':
287                         $addresses = self::parseAdresslist($value);
288                         foreach ($addresses as $address) {
289                             $this->setFrom($address['address'], $address['name']);
290                         }
291                         break;
292                         
293                     case 'message-id':
294                         $this->setMessageId($value);
295                         break;
296                         
297                     case 'return-path':
298                         $this->setReturnPath($value);
299                         break;
300                         
301                     case 'subject':
302                         $this->setSubject($value);
303                         break;
304                         
305                     case 'to':
306                         $addresses = self::parseAdresslist($value);
307                         foreach ($addresses as $address) {
308                             $this->addTo($address['address'], $address['name']);
309                         }
310                         break;
311                         
312                     default:
313                         $this->addHeader($header, $value);
314                         break;
315                 }
316             }
317         }
318         
319         return $this;
320     }
321
322     /**
323      * Sets Sender-header and sender of the message
324      *
325      * @param  string    $email
326      * @param  string    $name
327      * @return Zend_Mail Provides fluent interface
328      * @throws Zend_Mail_Exception if called subsequent times
329      */
330     public function setSender($email, $name = '')
331     {
332         if ($this->_sender === null) {
333             $email = strtr($email,"\r\n\t",'???');
334             $this->_from = $email;
335             $this->_storeHeader('Sender', $this->_encodeHeader('"'.$name.'"').' <'.$email.'>', true);
336         } else {
337             throw new Zend_Mail_Exception('Sender Header set twice');
338         }
339         return $this;
340     }
341     
342     /**
343      * Formats e-mail address
344      * 
345      * NOTE: we always add quotes to the name as this caused problems when name is encoded
346      * @see Zend_Mail::_formatAddress
347      *
348      * @param string $email
349      * @param string $name
350      * @return string
351      */
352     protected function _formatAddress($email, $name)
353     {
354         if ($name === '' || $name === null || $name === $email) {
355             return $email;
356         } else {
357             $encodedName = $this->_encodeHeader($name);
358             $format = '"%s" <%s>';
359             return sprintf($format, $encodedName, $email);
360         }
361     }
362
363     /**
364      * check if Zend_Mail_Message is/contains calendar iMIP message
365      * 
366      * @param Zend_Mail_Message $zmm
367      * @return boolean
368      */
369     public static function isiMIPMail(Zend_Mail_Message $zmm)
370     {
371         foreach ($zmm as $part) {
372             if (preg_match('/text\/calendar/', $part->contentType)) {
373                 return TRUE;
374             }
375         }
376         
377         return FALSE;
378     }
379     
380     /**
381      * get decoded body content
382      * 
383      * @param Zend_Mime_Part $zmp
384      * @param array $partStructure
385      * @param boolean $appendCharsetFilter
386      * @return string
387      */
388     public static function getDecodedContent(Zend_Mime_Part $zmp, $_partStructure = NULL, $appendCharsetFilter = TRUE)
389     {
390         $charset = self::_getCharset($zmp, $_partStructure);
391         if ($appendCharsetFilter) {
392             $charset = self::_appendCharsetFilter($zmp, $charset);
393         }
394         $encoding = ($_partStructure && ! empty($_partStructure['encoding'])) ? $_partStructure['encoding'] : $zmp->encoding;
395         
396         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
397             . " Trying to decode mime part content. Encoding/charset: " . $encoding . ' / ' . $charset);
398         
399         // need to set error handler because stream_get_contents just throws a E_WARNING
400         set_error_handler('Tinebase_Mail::decodingErrorHandler', E_WARNING);
401         try {
402             $body = $zmp->getDecodedContent();
403             restore_error_handler();
404             
405         } catch (Tinebase_Exception $e) {
406             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__
407                 . " Decoding of " . $zmp->encoding . '/' . $encoding . ' encoded message failed: ' . $e->getMessage());
408             
409             // trying to fix decoding problems
410             restore_error_handler();
411             $zmp->resetStream();
412             if (preg_match('/convert\.quoted-printable-decode/', $e->getMessage())) {
413                 if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' Trying workaround for http://bugs.php.net/50363.');
414                 $body = quoted_printable_decode(stream_get_contents($zmp->getRawStream()));
415                 $body = iconv($charset, 'utf-8', $body);
416             } else {
417                 if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' Try again with fallback encoding.');
418                 $zmp->appendDecodeFilter(self::_getDecodeFilter());
419                 set_error_handler('Tinebase_Mail::decodingErrorHandler', E_WARNING);
420                 try {
421                     $body = $zmp->getDecodedContent();
422                     restore_error_handler();
423                 } catch (Tinebase_Exception $e) {
424                     restore_error_handler();
425                     if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' Fallback encoding failed. Trying base64_decode().');
426                     $zmp->resetStream();
427                     $body = base64_decode(stream_get_contents($zmp->getRawStream()));
428                     $body = iconv($charset, 'utf-8', $body);
429                 }
430             }
431         }
432         
433         return $body;
434     }
435     /**
436      * convert charset (and return charset)
437      *
438      * @param  Zend_Mime_Part  $_part
439      * @param  array           $_structure
440      * @return string   
441      */
442     protected static function _getCharset(Zend_Mime_Part $_part, $_structure = NULL)
443     {
444         return ($_structure && isset($_structure['parameters']['charset'])) 
445             ? $_structure['parameters']['charset']
446             : ($_part->charset ? $_part->charset : self::DEFAULT_FALLBACK_CHARSET);
447     }
448     
449     /**
450      * convert charset (and return charset)
451      *
452      * @param  Zend_Mime_Part  $_part
453      * @param  string          $charset
454      * @return string   
455      */
456     protected static function _appendCharsetFilter(Zend_Mime_Part $_part, $charset)
457     {
458         if ($charset == 'utf8') {
459             $charset = 'utf-8';
460         } else if ($charset == 'us-ascii') {
461             // us-ascii caused problems with iconv encoding to utf-8
462             $charset = self::DEFAULT_FALLBACK_CHARSET;
463         } else if (strpos($charset, '.') !== false) {
464             // the stream filter does not like charsets with a dot in its name
465             // stream_filter_append(): unable to create or locate filter "convert.iconv.ansi_x3.4-1968/utf-8//IGNORE"
466             $charset = self::DEFAULT_FALLBACK_CHARSET;
467         } else if (iconv($charset, 'utf-8', '') === false) {
468             // check if charset is supported by iconv
469             $charset = self::DEFAULT_FALLBACK_CHARSET;
470         }
471         
472         $_part->appendDecodeFilter(self::_getDecodeFilter($charset));
473         
474         return $charset;
475     }
476     
477     /**
478      * get decode filter for stream_filter_append
479      * 
480      * @param string $_charset
481      * @return string
482      */
483     protected static function _getDecodeFilter($_charset = self::DEFAULT_FALLBACK_CHARSET)
484     {
485         if (in_array(strtolower($_charset), array('iso-8859-1', 'windows-1252', 'iso-8859-15')) && extension_loaded('mbstring')) {
486             require_once 'StreamFilter/ConvertMbstring.php';
487             $filter = 'convert.mbstring';
488         } else {
489             $filter = "convert.iconv.$_charset/utf-8//IGNORE";
490         }
491         
492         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' Appending decode filter: ' . $filter);
493         
494         return $filter;
495     }
496     
497     /**
498      * error exception handler for iconv decoding errors / only gets E_WARNINGs
499      *
500      * NOTE: PHP < 5.3 don't throws exceptions for Catchable fatal errors per default,
501      * so we convert them into exceptions manually
502      *
503      * @param integer $severity
504      * @param string $errstr
505      * @param string $errfile
506      * @param integer $errline
507      * @throws Tinebase_Exception
508      * 
509      * @todo maybe we can remove that because php 5.3+ is required now
510      */
511     public static function decodingErrorHandler($severity, $errstr, $errfile, $errline)
512     {
513         Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . " $errstr in {$errfile}::{$errline} ($severity)");
514         
515         throw new Tinebase_Exception($errstr);
516     }
517     
518     /**
519      * parse address list
520      *
521      * @param string $_adressList
522      * @return array
523      */
524     public static function parseAdresslist($_addressList)
525     {
526         if (strpos($_addressList, ',') !== FALSE && substr_count($_addressList, '@') == 1) {
527             // we have a comma in the name -> do not split string!
528             $addresses = array($_addressList);
529         } else {
530             // create stream to be used with fgetcsv
531             $stream = fopen("php://temp", 'r+');
532             fputs($stream, $_addressList);
533             rewind($stream);
534             
535             // alternative solution to create stream; yet untested
536             #$stream = fopen('data://text/plain;base64,' . base64_encode($_addressList), 'r');
537             
538             // split addresses
539             $addresses = fgetcsv($stream);
540         }
541         
542         if (! is_array($addresses)) {
543             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . 
544                 ' Could not parse addresses: ' . var_export($addresses, TRUE));
545             return array();
546         }
547         
548         foreach ($addresses as $key => $address) {
549             if (preg_match('/(.*)<(.+@[^@]+)>/', $address, $matches)) {
550                 $name = trim(trim($matches[1]), '"');
551                 $address = trim($matches[2]);
552                 $addresses[$key] = array('name' => substr($name, 0, 250), 'address' => $address);
553             } else {
554                 $address = preg_replace('/[,;]*/i', '', $address);
555                 $addresses[$key] = array('name' => null, 'address' => trim($address));
556             }
557         }
558
559         return $addresses;
560     }
561
562     /**
563      * convert text to html
564      * - replace quotes ('>  ') with blockquotes 
565      * - does htmlspecialchars()
566      * - converts linebreaks to <br />
567      * 
568      * @param string $text
569      * @param string $blockquoteClass
570      * @return string
571      */
572     public static function convertFromTextToHTML($text, $blockquoteClass = null)
573     {
574         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Input: ' . $text);
575         
576         $lines = preg_split('/\r\n|\n|\r/', $text);
577         $result = array();
578         $indention = 0;
579         foreach ($lines as $line) {
580             // get indention level and remove quotes
581             if (preg_match('/^>[> ]*/', $line, $matches)) {
582                 $indentionLevel = substr_count($matches[0], '>');
583                 $line = str_replace($matches[0], '', $line);
584             } else {
585                 $indentionLevel = 0;
586             }
587             
588             // convert html special chars
589             $line = htmlspecialchars($line, ENT_COMPAT, 'UTF-8');
590             
591             // set blockquote tags for current indentionLevel
592             while ($indention < $indentionLevel) {
593                 $class = $blockquoteClass ? 'class="' . $blockquoteClass . '"' : '';
594                 $line = '<blockquote ' . $class . '>' . $line;
595                 $indention++;
596             }
597             while ($indention > $indentionLevel) {
598                 $line = '</blockquote>' . $line;
599                 $indention--;
600             }
601             
602             $result[] = $line;
603             
604             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Line: ' . $line);
605         }
606         
607         $result = implode('<br />', $result);
608         
609         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Result: ' . $result);
610         
611         return $result;
612     }
613 }