1<?php
2/**
3 * Horde_Core_ActiveSync_Mail::
4 *
5 * @copyright 2010-2017 Horde LLC (http://www.horde.org/)
6 * @license http://www.horde.org/licenses/lgpl21 LGPL
7 * @author  Michael J Rubinsky <mrubinsk@horde.org>
8 * @package Core
9 */
10/**
11 * Horde_Core_ActiveSync_Mail::
12 *
13 * Wraps functionality related to sending/replying/forwarding email from
14 * EAS clients.
15 *
16 * @copyright 2010-2017 Horde LLC (http://www.horde.org/)
17 * @license http://www.horde.org/licenses/lgpl21 LGPL
18 * @author  Michael J Rubinsky <mrubinsk@horde.org>
19 * @package Core
20 *
21 * @property-read Horde_ActiveSync_Imap_Adapter $imapAdapter  The imap adapter.
22 * @property      boolean $replacemime  Flag to indicate we are to replace the MIME contents of a SMART request.
23 * @property-read integer $id  The UID of the source email for any SMARTREPLY or SMARTFORWARD requests.
24 * @property-read boolean $reply  Flag indicating a SMARTREPLY request.
25 * @property-read boolean $forward  Flag indicating a SMARTFORWARD request.
26 * @property-read Horde_Mime_Header $header  The headers used when sending the email.
27 * @property-read string $parentFolder  he email folder that contains the source email for any SMARTREPLY or SMARTFORWARD requests.
28 */
29class Horde_Core_ActiveSync_Mail
30{
31    const HTML_BLOCKQUOTE = '<blockquote type="cite" style="border-left:2px solid blue;margin-left:2px;padding-left:12px;">';
32
33    /**
34     * The headers used when sending the email.
35     *
36     * @var Horde_Mime_Header
37     */
38    protected $_headers;
39
40    /**
41     * The raw message body sent from the EAS client.
42     *
43     * @var Horde_ActiveSync_Rfc822
44     */
45    protected $_raw;
46
47    /**
48     * The email folder that contains the source email for any SMARTREPLY or
49     * SMARTFORWARD requests.
50     *
51     * @var string
52     */
53    protected $_parentFolder = false;
54
55    /**
56     * The UID of the source email for any SMARTREPLY or SMARTFORWARD requests.
57     *
58     * @var integer
59     */
60    protected $_id;
61
62    /**
63     * Flag indicating a SMARTFORWARD request.
64     *
65     * @var boolean
66     */
67    protected $_forward = false;
68
69    /**
70     * Flag indicating a SMARTREPLY request.
71     *
72     * @var boolean
73     */
74    protected $_reply = false;
75
76    /**
77     * Flag indicating the client requested to replace the MIME part
78     * a SMARTREPLY or SMARTFORWARD request.
79     *
80     * @var boolean
81     */
82    protected $_replacemime = false;
83
84    /**
85     * The current EAS user.
86     *
87     * @var string
88     */
89    protected $_user;
90
91    /**
92     * Flag to indicate reply position for SMARTREPLY requests.
93     *
94     * @var boolean
95     */
96    protected $_replyTop = false;
97
98    /**
99     * Internal cache of the mailer used when sending SMART[REPLY|FORWARD].
100     * Used to fetch the raw message used to save to sent mail folder.
101     *
102     * @var Horde_Mime_Mail
103     */
104    protected $_mailer;
105
106    /**
107     * The message object representing the source email for a
108     * SMART[REPLY|FORWARD] request.
109     *
110     * @var Horde_ActiveSync_Imap_Message
111     */
112    protected $_imapMessage;
113
114    /**
115     * The imap adapter needed to fetch the source IMAP message if needed.
116     *
117     * @var Horde_ActiveSync_Imap_Adapter
118     */
119    protected $_imap;
120
121    /**
122     * EAS version in use.
123     *
124     * @var string
125     */
126    protected $_version;
127
128    /**
129     * Array of email addresses to forward message to, if using SMART_FORWARD.
130     *
131     * @var array
132     * @since  2.31.0
133     */
134    protected $_forwardees = array();
135
136    /**
137     * Const'r
138     *
139     * @param Horde_ActiveSync_Imap_Adapter $imap  The IMAP adapter.
140     * @param string $user                         EAS user.
141     * @param integer $eas_version                 EAS version in use.
142     */
143    public function __construct(
144        Horde_ActiveSync_Imap_Adapter $imap, $user, $eas_version)
145    {
146        $this->_imap = $imap;
147        $this->_user = $user;
148        $this->_version = $eas_version;
149    }
150
151    public function &__get($property)
152    {
153        switch ($property) {
154        case 'imapMessage':
155            if (!isset($this->_imapMessage)) {
156                $this->_getImapMessage();
157            }
158            return $this->_imapMessage;
159        case 'replacemime':
160        case 'id':
161        case 'reply':
162        case 'forward':
163        case 'headers':
164        case 'parentFolder':
165            $property = '_' . $property;
166            return $this->$property;
167        }
168    }
169
170    public function __set($property, $value)
171    {
172        if ($property == 'replacemime') {
173            $this->_replacemime = $value;
174        }
175    }
176
177
178    /**
179     * Set the raw message content received from the EAS client to send.
180     *
181     * @param Horde_ActiveSync_Rfc822 $raw  The data from the EAS client.
182     */
183    public function setRawMessage(Horde_ActiveSync_Rfc822 $raw)
184    {
185        $this->_headers = $raw->getHeaders();
186
187        // Attempt to always use the identity's From address, but fall back
188        // to the device's sent value if it's not present.
189        if ($from = $this->_getIdentityFromAddress()) {
190            $this->_headers->removeHeader('From');
191            $this->_headers->addHeader('From', $from);
192        }
193
194        // Reply-To?
195        if ($replyto = $this->_getReplyToAddress()) {
196            $this->_headers->addHeader('Reply-To', $replyto);
197        }
198
199        $this->_raw = $raw;
200    }
201
202    /**
203     * Set this as a SMARTFORWARD requests.
204     *
205     * @param string $parent  The folder containing the source message.
206     * @param integer $id     The source message UID.
207     * @param  array $params  Additional parameters: @since 2.31.0
208     *   - forwardees: An array of email addresses that this message will be
209     *                 forwarded to. DEFAULT: Recipients are taken from raw
210     *                 message.
211     * @throws Horde_ActiveSync_Exception
212     */
213    public function setForward($parent, $id, $params = array())
214    {
215        if (!empty($this->_reply)) {
216            throw new Horde_ActiveSync_Exception('Cannot set both Forward and Reply.');
217        }
218        $this->_id = $id;
219        $this->_parentFolder = $parent;
220        $this->_forward = true;
221
222        if (!empty($params['forwardees'])) {
223            $this->_forwardees = $params['forwardees'];
224        }
225    }
226
227    /**
228     * Set this as a SMARTREPLY requests.
229     *
230     * @param string $parent  The folder containing the source message.
231     * @param integer $id     The source message UID.
232     * @throws Horde_ActiveSync_Exception
233     */
234    public function setReply($parent, $id)
235    {
236        if (!empty($this->_forward)) {
237            throw new Horde_ActiveSync_Exception('Cannot set both Forward and Reply.');
238        }
239        $this->_id = $id;
240        $this->_parentFolder = $parent;
241        $this->_reply = true;
242    }
243
244    /**
245     * Send the email.
246     *
247     * @throws Horde_ActiveSync_Exception
248     */
249    public function send()
250    {
251        if (empty($this->_raw)) {
252            throw new Horde_ActiveSync_Exception('No data set or received from EAS client.');
253        }
254        $this->_callPreSendHook();
255        if (!$this->_parentFolder || ($this->_parentFolder && $this->_replacemime)) {
256            $this->_sendRaw();
257        } else {
258            $this->_sendSmart();
259        }
260    }
261
262    protected function _callPreSendHook()
263    {
264        $hooks = $GLOBALS['injector']->getInstance('Horde_Core_Hooks');
265        $params = array(
266            'raw' => $this->_raw,
267            'imap_msg' => $this->imapMessage,
268            'parent' => $this->_parentFolder,
269            'reply' => $this->_reply,
270            'forward' => $this->_forward);
271        try {
272            if (!$result = $hooks->callHook('activesync_email_presend', 'horde', array($params))) {
273                throw new Horde_ActiveSync_Exception('There was an issue running the activesync_email_presend hook.');
274            }
275            if ($result instanceof Horde_ActiveSync_Mime) {
276                $this->_raw->replaceMime($result->base);
277            }
278        } catch (Horde_Exception_HookNotSet $e) {
279        }
280    }
281
282    /**
283     * Get the raw message suitable for saving to the sent email folder.
284     *
285     * @return stream  A stream contianing the raw message.
286     */
287    public function getSentMail()
288    {
289        if (!empty($this->_mailer)) {
290            return $this->_mailer->getRaw();
291        }
292        $stream = new Horde_Stream_Temp(array('max_memory' => 262144));
293        $stream->add($this->_headers->toString(array('charset' => 'UTF-8')) . $this->_raw->getMessage(), true);
294        return $stream;
295    }
296
297    /**
298     * Send the raw message received from the client. E.g., NOT a SMART request.
299     *
300     * @throws Horde_ActiveSync_Exception
301     */
302    protected function _sendRaw()
303    {
304        $h_array = $this->_headers->toArray(array('charset' => 'UTF-8'));
305        $recipients = $h_array['To'];
306        if (!empty($h_array['Cc'])) {
307            $recipients .= ',' . $h_array['Cc'];
308        }
309        if (!empty($h_array['Bcc'])) {
310            $recipients .= ',' . $h_array['Bcc'];
311            unset($h_array['Bcc']);
312        }
313
314        try {
315            $GLOBALS['injector']->getInstance('Horde_Mail')
316                ->send($recipients, $h_array, $this->_raw->getMessage()->stream);
317        } catch (Horde_Mail_Exception $e) {
318            throw new Horde_ActiveSync_Exception($e->getMessage());
319        } catch (InvalidArgumentException $e) {
320            // Some clients (HTC One devices, for one) generate HTML signatures
321            // that contain line lengths too long for servers without BINARYMIME
322            // to send. If we are here, see if that's the reason why by trying
323            // to wrap any text/html parts.
324            if (!$this->_tryWithoutBinary($recipients, $h_array)) {
325                throw new Horde_ActiveSync_Exception($e->getMessage());
326            }
327        }
328
329        // Replace MIME? Don't have original body, but still need headers.
330        // @TODO: Get JUST the headers?
331        if ($this->_replacemime) {
332            try {
333                $this->_getImapMessage();
334            } catch (Horde_Exception_NotFound $e) {
335                throw new Horde_ActiveSync_Exception($e->getMessage());
336            }
337        }
338    }
339
340    /**
341     * Some clients (HTC One devices, for one) generate HTML signatures
342     * that contain line lengths too long for servers without BINARYMIME to
343     * send. If we are here, see if that's the reason by checking content
344     * encoding and trying again.
345     *
346     * @return boolean
347     */
348    protected function _tryWithoutBinary($recipients, array $headers)
349    {
350        // All we need to do is re-assign the mime object. This will cause the
351        // content transfer encoding to be re-evaulated and set to an approriate
352        // value if needed.
353        $mime = $this->_raw->getMimeObject();
354        $this->_raw->replaceMime($mime);
355        try {
356            $GLOBALS['injector']->getInstance('Horde_Mail')
357                ->send($recipients, $headers, $this->_raw->getMessage()->stream);
358        } catch (Exception $e) {
359            return false;
360        }
361
362        return true;
363    }
364
365    /**
366     * Sends a SMART response.
367     *
368     * @throws Horde_ActiveSync_Exception
369     */
370    protected function _sendSmart()
371    {
372        $mime_message = $this->_raw->getMimeObject();
373        // Need to remove content-type header from the incoming raw message
374        // since in a smart request, we actually construct the full MIME msg
375        // ourselves and the content-type in _headers only applies to the reply
376        // text sent from the client, not the fully generated MIME message.
377        $this->_headers->removeHeader('Content-Type');
378        $this->_headers->removeHeader('Content-Transfer-Encoding');
379
380        // Check for EAS 16.0 Forwardees
381        if (!empty($this->_forwardees)) {
382            $list = new Horde_Mail_Rfc822_List();
383            foreach ($this->_forwardees as $forwardee) {
384                $to = new Horde_Mail_Rfc822_Address($forwardee->email);
385                $to->personal = $forwardee->name;
386                $list->add($to);
387            }
388            $this->_headers->add('To', $list->writeAddress());
389        }
390
391        $mail = new Horde_Mime_Mail($this->_headers->toArray(array('charset' => 'UTF-8')));
392        $base_part = $this->imapMessage->getStructure();
393        $plain_id = $base_part->findBody('plain');
394        $html_id = $base_part->findBody('html');
395
396        try {
397            $body_data = $this->imapMessage->getMessageBodyData(array(
398                'protocolversion' => $this->_version,
399                'bodyprefs' => array(Horde_ActiveSync::BODYPREF_TYPE_MIME => true))
400            );
401        } catch (Horde_Exception_NotFound $e) {
402            throw new Horde_ActiveSync_Exception($e->getMessage());
403        }
404        if (!empty($html_id)) {
405            $mail->setHtmlBody($this->_getHtmlPart($html_id, $mime_message, $body_data, $base_part));
406        } elseif (!empty($plain_id)) {
407            $mail->setBody($this->_getPlainPart($plain_id, $mime_message, $body_data, $base_part));
408        }
409        if ($this->_forward) {
410            foreach ($base_part->contentTypeMap() as $mid => $type) {
411                if ($this->imapMessage->isAttachment($mid, $type)) {
412                    $mail->addMimePart($this->imapMessage->getMimePart($mid));
413                }
414            }
415        }
416        foreach ($mime_message->contentTypeMap() as $mid => $type) {
417            if ($mid != 0 && $mid != $mime_message->findBody('plain') && $mid != $mime_message->findBody('html')) {
418                $mail->addMimePart($mime_message->getPart($mid));
419            }
420        }
421
422        try {
423            $mail->send($GLOBALS['injector']->getInstance('Horde_Mail'));
424            $this->_mailer = $mail;
425        } catch (Horde_Mime_Exception $e) {
426            throw new Horde_ActiveSync_Exception($e);
427        }
428    }
429
430    /**
431     * Build the text part of a SMARTREPLY or SMARTFORWARD
432     *
433     * @param string $plain_id               The MIME part id of the plaintext
434     *                                       part of $base_part.
435     * @param Horde_Mime_Part $mime_message  The MIME part of the email to be
436     *                                       sent.
437     * @param array $body_data @see Horde_ActiveSync_Imap_Message::getMessageBodyData()
438     * @param Horde_Mime_Part $base_part     The base MIME part of the source
439     *                                       message for a SMART request.
440     *
441     * @return string  The plaintext part of the email message that is being sent.
442     */
443    protected function _getPlainPart(
444        $plain_id, Horde_Mime_Part $mime_message, array $body_data, Horde_Mime_Part $base_part)
445    {
446        if (!$id = $mime_message->findBody('plain')) {
447            $smart_text = Horde_ActiveSync_Utils::ensureUtf8(
448                $mime_message->getPart($mime_message->findBody())->getContents(),
449                $mime_message->getCharset()
450            );
451            $smart_text = $this->_tidyHtml($smart_text);
452            $smart_text = self::html2text($smart_text);
453        } else {
454            $smart_text = Horde_ActiveSync_Utils::ensureUtf8(
455                $mime_message->getPart($id)->getContents(),
456                $mime_message->getCharset());
457        }
458
459        if ($this->_forward) {
460            return $smart_text . $this->_forwardText($body_data, $base_part->getPart($plain_id));
461        }
462
463        return $smart_text . $this->_replyText($body_data, $base_part->getPart($plain_id));
464    }
465
466    /**
467     * Build the HTML part of a SMARTREPLY or SMARTFORWARD
468     *
469     * @param string $html_id                The MIME part id of the html part of
470     *                                       $base_part.
471     * @param Horde_Mime_Part $mime_message  The MIME part of the email to be
472     *                                       sent.
473     * @param array $body_data @see Horde_ActiveSync_Imap_Message::getMessageBodyData()
474     * @param Horde_Mime_Part $base_part     The base MIME part of the source
475     *                                       message for a SMART request.
476     *
477     * @return string  The plaintext part of the email message that is being sent.
478     */
479    protected function _getHtmlPart($html_id, $mime_message, $body_data, $base_part)
480    {
481        if (!$id = $mime_message->findBody('html')) {
482            $smart_text = self::text2html(
483                Horde_ActiveSync_Utils::ensureUtf8(
484                    $mime_message->getPart($mime_message->findBody('plain'))->getContents(),
485                    $mime_message->getCharset()));
486        } else {
487            $smart_text = Horde_ActiveSync_Utils::ensureUtf8(
488                $mime_message->getPart($id)->getContents(),
489                $mime_message->getCharset());
490        }
491
492        if ($this->_forward) {
493            return $smart_text . $this->_forwardText($body_data, $base_part->getPart($html_id), true);
494        }
495        return $smart_text . $this->_replyText($body_data, $base_part->getPart($html_id), true);
496    }
497
498    /**
499     * Fetch the source message for a SMART request from the IMAP server.
500     *
501     * @throws Horde_Exception_NotFound
502     */
503    protected function _getImapMessage()
504    {
505        if (empty($this->_id) || empty($this->_parentFolder)) {
506            return;
507        }
508        $this->_imapMessage = array_pop($this->_imap->getImapMessage($this->_parentFolder, $this->_id, array('headers' => true)));
509        if (empty($this->_imapMessage)) {
510            throw new Horde_Exception_NotFound('The forwarded/replied message was not found.');
511        }
512    }
513
514    /**
515     * Return the current user's From address.
516     *
517     * @return string  A RFC822 valid email string.
518     */
519    protected function _getIdentityFromAddress()
520    {
521        global $prefs;
522
523        $ident = $GLOBALS['injector']
524            ->getInstance('Horde_Core_Factory_Identity')
525            ->create($this->_user);
526
527        $as_ident = $prefs->getValue('activesync_identity');
528        $name = $ident->getValue('fullname', $as_ident == 'horde' ? $prefs->getValue('default_identity') : $prefs->getValue('activesync_identity'));
529        $from_addr = $ident->getValue('from_addr', $as_ident == 'horde' ? $prefs->getValue('default_identity') : $prefs->getValue('activesync_identity'));
530        if (empty($from_addr)) {
531            return;
532        }
533        $rfc822 = new Horde_Mail_Rfc822_Address($from_addr);
534        $rfc822->personal = $name;
535
536        return $rfc822->encoded;
537    }
538
539    /**
540     * Return the current user's ReplyTo address, if available.
541     *
542     * @return string A RFC822 valid email string.
543     */
544    protected function _getReplyToAddress()
545    {
546        global $prefs;
547
548        $ident = $GLOBALS['injector']
549            ->getInstance('Horde_Core_Factory_Identity')
550            ->create($this->_user);
551
552        $as_ident = $prefs->getValue('activesync_identity');
553        $replyto_addr = $ident->getValue('replyto_addr', $as_ident == 'horde' ? $prefs->getValue('default_identity') : $prefs->getValue('activesync_identity'));
554        if (empty($replyto_addr)) {
555            return;
556        }
557        $rfc822 = new Horde_Mail_Rfc822_Address($replyto_addr);
558
559        return $rfc822->encoded;
560    }
561
562    /**
563     * Return the body of the forwarded message in the appropriate type.
564     *
565     * @param array $body_data         The body data array of the source msg.
566     * @param Horde_Mime_Part $part    The body part of the email to send.
567     * @param boolean $html            Is this an html part?
568     *
569     * @return string  The propertly formatted forwarded body text.
570     */
571    protected function _forwardText(array $body_data, Horde_Mime_Part $part, $html = false)
572    {
573        return $this->_msgBody($body_data, $part, $html);
574    }
575
576    /**
577     * Return the body of the replied message in the appropriate type.
578     *
579     * @param array $body_data         The body data array of the source msg.
580     * @param Horde_Mime_Part $partId  The body part of the email to send.
581     * @param boolean $html            Is this an html part?
582     *
583     * @return string  The propertly formatted replied body text.
584     */
585    protected function _replyText(array $body_data, Horde_Mime_Part $part, $html = false)
586    {
587        $msg = $this->_msgBody($body_data, $part, $html, true);
588        if (!empty($msg) && $html) {
589            return self::HTML_BLOCKQUOTE . $msg . '</blockquote><br /><br />';
590        }
591        return empty($msg)
592            ? '[' . Horde_Core_Translation::t("No message body text") . ']'
593            : $msg;
594    }
595
596    /**
597     * Return the body text of the original email from a smart request.
598     *
599     * @param array $body_data       The body data array of the source msg.
600     * @param Horde_Mime_Part $part  The body mime part of the email to send.
601     * @param boolean $html          Do we want an html body?
602     * @param boolean $flow          Should the body be flowed?
603     *
604     * @return string  The properly formatted/flowed message body.
605     */
606    protected function _msgBody(array $body_data, Horde_Mime_Part $part, $html, $flow = false)
607    {
608        $subtype = $html == true ? 'html' : 'plain';
609        $msg = (string)$body_data[$subtype]['body'];
610        if (!$html) {
611            if ($part->getContentTypeParameter('format') == 'flowed') {
612                $flowed = new Horde_Text_Flowed($msg, 'UTF-8');
613                if (Horde_String::lower($part->getContentTypeParameter('delsp')) == 'yes') {
614                    $flowed->setDelSp(true);
615                }
616                $flowed->setMaxLength(0);
617                $msg = $flowed->toFixed(false);
618            } else {
619                // If not flowed, remove padding at eol
620                $msg = preg_replace("/\s*\n/U", "\n", $msg);
621            }
622            if ($flow) {
623                $flowed = new Horde_Text_Flowed($msg, 'UTF-8');
624                $msg = $flowed->toFlowed(true);
625            }
626        } else {
627            return $this->_tidyHtml($msg);
628        }
629
630        return $msg;
631    }
632
633    /**
634     * Attempt to sanitize the provided $html string.
635     * Uitilizes the Cleanhtml filter if able, otherwise
636     * uses Horde_Dom
637     *
638     * @param  string $html  An HTML string to sanitize.
639     *
640     * @return string  The sanitized HTML.
641     */
642    protected function _tidyHtml($html)
643    {
644        // This filter requires the tidy extenstion.
645        if (Horde_Util::extensionExists('tidy')) {
646            return Horde_Text_Filter::filter(
647                $html,
648                'Cleanhtml',
649                array('body_only' => true)
650            );
651        } else {
652            // If no tidy, use Horde_Dom.
653            $dom = new Horde_Domhtml($html, 'UTF-8');
654            return $dom->returnBody();
655        }
656    }
657
658    /**
659     * Shortcut function to convert text -> HTML.
660     *
661     * @param string $msg  The message text.
662     *
663     * @return string  HTML text.
664     */
665    public static function text2html($msg)
666    {
667        return Horde_Text_Filter::filter(
668            $msg,
669            'Text2html',
670            array(
671                'flowed' => self::HTML_BLOCKQUOTE,
672                'parselevel' => Horde_Text_Filter_Text2html::MICRO)
673        );
674    }
675
676    public static function html2text($msg)
677    {
678        return Horde_Text_Filter::filter($msg, 'Html2text', array('nestingLimit' => 1000));
679    }
680
681}