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}