Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * The Mail Pickup Manager. 19 * 20 * @package tool_messageinbound 21 * @copyright 2014 Andrew Nicols 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 namespace tool_messageinbound; 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 /** 30 * Mail Pickup Manager. 31 * 32 * @copyright 2014 Andrew Nicols 33 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 34 */ 35 class manager { 36 37 /** 38 * @var string The main mailbox to check. 39 */ 40 const MAILBOX = 'INBOX'; 41 42 /** 43 * @var string The mailbox to store messages in when they are awaiting confirmation. 44 */ 45 const CONFIRMATIONFOLDER = 'tobeconfirmed'; 46 47 /** 48 * @var string The flag for seen/read messages. 49 */ 50 const MESSAGE_SEEN = '\seen'; 51 52 /** 53 * @var string The flag for flagged messages. 54 */ 55 const MESSAGE_FLAGGED = '\flagged'; 56 57 /** 58 * @var string The flag for deleted messages. 59 */ 60 const MESSAGE_DELETED = '\deleted'; 61 62 /** 63 * @var \string IMAP folder namespace. 64 */ 65 protected $imapnamespace = null; 66 67 /** 68 * @var \Horde_Imap_Client_Socket A reference to the IMAP client. 69 */ 70 protected $client = null; 71 72 /** 73 * @var \core\message\inbound\address_manager A reference to the Inbound Message Address Manager instance. 74 */ 75 protected $addressmanager = null; 76 77 /** 78 * @var \stdClass The data for the current message being processed. 79 */ 80 protected $currentmessagedata = null; 81 82 /** 83 * Retrieve the connection to the IMAP client. 84 * 85 * @return bool Whether a connection was successfully established. 86 */ 87 protected function get_imap_client() { 88 global $CFG; 89 90 if (!\core\message\inbound\manager::is_enabled()) { 91 // E-mail processing not set up. 92 mtrace("Inbound Message not fully configured - exiting early."); 93 return false; 94 } 95 96 mtrace("Connecting to {$CFG->messageinbound_host} as {$CFG->messageinbound_hostuser}..."); 97 98 $configuration = array( 99 'username' => $CFG->messageinbound_hostuser, 100 'password' => $CFG->messageinbound_hostpass, 101 'hostspec' => $CFG->messageinbound_host, 102 'secure' => $CFG->messageinbound_hostssl, 103 'debug' => empty($CFG->debugimap) ? null : fopen('php://stderr', 'w'), 104 ); 105 106 if (strpos($configuration['hostspec'], ':')) { 107 $hostdata = explode(':', $configuration['hostspec']); 108 if (count($hostdata) === 2) { 109 // A hostname in the format hostname:port has been provided. 110 $configuration['hostspec'] = $hostdata[0]; 111 $configuration['port'] = $hostdata[1]; 112 } 113 } 114 115 // XOAUTH2. 116 if ($CFG->messageinbound_hostoauth != '') { 117 // Get the issuer. 118 $issuer = \core\oauth2\api::get_issuer($CFG->messageinbound_hostoauth); 119 // Validate the issuer and check if it is enabled or not. 120 if ($issuer && $issuer->get('enabled')) { 121 // Get the OAuth Client. 122 if ($oauthclient = \core\oauth2\api::get_system_oauth_client($issuer)) { 123 $xoauth2token = new \Horde_Imap_Client_Password_Xoauth2( 124 $configuration['username'], 125 $oauthclient->get_accesstoken()->token 126 ); 127 $configuration['xoauth2_token'] = $xoauth2token; 128 // Password is not necessary when using OAuth2 but Horde still needs it. We just set a random string here. 129 $configuration['password'] = random_string(64); 130 } 131 } 132 } 133 134 $this->client = new \Horde_Imap_Client_Socket($configuration); 135 136 try { 137 $this->client->login(); 138 mtrace("Connection established."); 139 140 // Ensure that mailboxes exist. 141 $this->ensure_mailboxes_exist(); 142 143 return true; 144 145 } catch (\Horde_Imap_Client_Exception $e) { 146 $message = $e->getMessage(); 147 throw new \moodle_exception('imapconnectfailure', 'tool_messageinbound', '', null, $message); 148 } 149 } 150 151 /** 152 * Shutdown and close the connection to the IMAP client. 153 */ 154 protected function close_connection() { 155 if ($this->client) { 156 $this->client->close(); 157 } 158 $this->client = null; 159 } 160 161 /** 162 * Get the confirmation folder imap name 163 * 164 * @return string 165 */ 166 protected function get_confirmation_folder() { 167 168 if ($this->imapnamespace === null) { 169 if ($this->client->queryCapability('NAMESPACE')) { 170 $namespaces = $this->client->getNamespaces(array(), array('ob_return' => true)); 171 $this->imapnamespace = $namespaces->getNamespace('INBOX'); 172 } else { 173 $this->imapnamespace = ''; 174 } 175 } 176 177 return $this->imapnamespace . self::CONFIRMATIONFOLDER; 178 } 179 180 /** 181 * Get the current mailbox information. 182 * 183 * @return \Horde_Imap_Client_Mailbox 184 * @throws \core\message\inbound\processing_failed_exception if the mailbox could not be opened. 185 */ 186 protected function get_mailbox() { 187 // Get the current mailbox. 188 $mailbox = $this->client->currentMailbox(); 189 190 if (isset($mailbox['mailbox'])) { 191 return $mailbox['mailbox']; 192 } else { 193 throw new \core\message\inbound\processing_failed_exception('couldnotopenmailbox', 'tool_messageinbound'); 194 } 195 } 196 197 /** 198 * Execute the main Inbound Message pickup task. 199 * 200 * @return bool 201 */ 202 public function pickup_messages() { 203 if (!$this->get_imap_client()) { 204 return false; 205 } 206 207 // Restrict results to messages which are unseen, and have not been flagged. 208 $search = new \Horde_Imap_Client_Search_Query(); 209 $search->flag(self::MESSAGE_SEEN, false); 210 $search->flag(self::MESSAGE_FLAGGED, false); 211 mtrace("Searching for Unseen, Unflagged email in the folder '" . self::MAILBOX . "'"); 212 $results = $this->client->search(self::MAILBOX, $search); 213 214 // We require the envelope data and structure of each message. 215 $query = new \Horde_Imap_Client_Fetch_Query(); 216 $query->envelope(); 217 $query->structure(); 218 219 // Retrieve the message id. 220 $messages = $this->client->fetch(self::MAILBOX, $query, array('ids' => $results['match'])); 221 222 mtrace("Found " . $messages->count() . " messages to parse. Parsing..."); 223 $this->addressmanager = new \core\message\inbound\address_manager(); 224 foreach ($messages as $message) { 225 $this->process_message($message); 226 } 227 228 // Close the client connection. 229 $this->close_connection(); 230 231 return true; 232 } 233 234 /** 235 * Process a message received and validated by the Inbound Message processor. 236 * 237 * @param \stdClass $maildata The data retrieved from the database for the current record. 238 * @return bool Whether the message was successfully processed. 239 * @throws \core\message\inbound\processing_failed_exception if the message cannot be found. 240 */ 241 public function process_existing_message(\stdClass $maildata) { 242 // Grab the new IMAP client. 243 if (!$this->get_imap_client()) { 244 return false; 245 } 246 247 // Build the search. 248 $search = new \Horde_Imap_Client_Search_Query(); 249 // When dealing with Inbound Message messages, we mark them as flagged and seen. Restrict the search to those criterion. 250 $search->flag(self::MESSAGE_SEEN, true); 251 $search->flag(self::MESSAGE_FLAGGED, true); 252 mtrace("Searching for a Seen, Flagged message in the folder '" . $this->get_confirmation_folder() . "'"); 253 254 // Match the message ID. 255 $search->headerText('message-id', $maildata->messageid); 256 $search->headerText('to', $maildata->address); 257 258 $results = $this->client->search($this->get_confirmation_folder(), $search); 259 260 // Build the base query. 261 $query = new \Horde_Imap_Client_Fetch_Query(); 262 $query->envelope(); 263 $query->structure(); 264 265 266 // Fetch the first message from the client. 267 $messages = $this->client->fetch($this->get_confirmation_folder(), $query, array('ids' => $results['match'])); 268 $this->addressmanager = new \core\message\inbound\address_manager(); 269 if ($message = $messages->first()) { 270 mtrace("--> Found the message. Passing back to the pickup system."); 271 272 // Process the message. 273 $this->process_message($message, true, true); 274 275 // Close the client connection. 276 $this->close_connection(); 277 278 mtrace("============================================================================"); 279 return true; 280 } else { 281 // Close the client connection. 282 $this->close_connection(); 283 284 mtrace("============================================================================"); 285 throw new \core\message\inbound\processing_failed_exception('oldmessagenotfound', 'tool_messageinbound'); 286 } 287 } 288 289 /** 290 * Tidy up old messages in the confirmation folder. 291 * 292 * @return bool Whether tidying occurred successfully. 293 */ 294 public function tidy_old_messages() { 295 // Grab the new IMAP client. 296 if (!$this->get_imap_client()) { 297 return false; 298 } 299 300 // Open the mailbox. 301 mtrace("Searching for messages older than 24 hours in the '" . 302 $this->get_confirmation_folder() . "' folder."); 303 $this->client->openMailbox($this->get_confirmation_folder()); 304 305 $mailbox = $this->get_mailbox(); 306 307 // Build the search. 308 $search = new \Horde_Imap_Client_Search_Query(); 309 310 // Delete messages older than 24 hours old. 311 $search->intervalSearch(DAYSECS, \Horde_Imap_Client_Search_Query::INTERVAL_OLDER); 312 313 $results = $this->client->search($mailbox, $search); 314 315 // Build the base query. 316 $query = new \Horde_Imap_Client_Fetch_Query(); 317 $query->envelope(); 318 319 // Retrieve the messages and mark them for removal. 320 $messages = $this->client->fetch($mailbox, $query, array('ids' => $results['match'])); 321 mtrace("Found " . $messages->count() . " messages for removal."); 322 foreach ($messages as $message) { 323 $this->add_flag_to_message($message->getUid(), self::MESSAGE_DELETED); 324 } 325 326 mtrace("Finished removing messages."); 327 $this->close_connection(); 328 329 return true; 330 } 331 332 /** 333 * Remove older verification failures. 334 * 335 * @return void 336 */ 337 public function tidy_old_verification_failures() { 338 global $DB; 339 $DB->delete_records_select('messageinbound_messagelist', 'timecreated < :time', ['time' => time() - DAYSECS]); 340 } 341 342 /** 343 * Process a message and pass it through the Inbound Message handling systems. 344 * 345 * @param \Horde_Imap_Client_Data_Fetch $message The message to process 346 * @param bool $viewreadmessages Whether to also look at messages which have been marked as read 347 * @param bool $skipsenderverification Whether to skip the sender verification stage 348 */ 349 public function process_message( 350 \Horde_Imap_Client_Data_Fetch $message, 351 $viewreadmessages = false, 352 $skipsenderverification = false) { 353 global $USER; 354 355 // We use the Client IDs several times - store them here. 356 $messageid = new \Horde_Imap_Client_Ids($message->getUid()); 357 358 mtrace("- Parsing message " . $messageid); 359 360 // First flag this message to prevent another running hitting this message while we look at the headers. 361 $this->add_flag_to_message($messageid, self::MESSAGE_FLAGGED); 362 363 if ($this->is_bulk_message($message, $messageid)) { 364 mtrace("- The message has a bulk header set. This is likely an auto-generated reply - discarding."); 365 return; 366 } 367 368 // Record the user that this script is currently being run as. This is important when re-processing existing 369 // messages, as \core\cron::setup_user is called multiple times. 370 $originaluser = $USER; 371 372 $envelope = $message->getEnvelope(); 373 $recipients = $envelope->to->bare_addresses; 374 foreach ($recipients as $recipient) { 375 if (!\core\message\inbound\address_manager::is_correct_format($recipient)) { 376 // Message did not contain a subaddress. 377 mtrace("- Recipient '{$recipient}' did not match Inbound Message headers."); 378 continue; 379 } 380 381 // Message contained a match. 382 $senders = $message->getEnvelope()->from->bare_addresses; 383 if (count($senders) !== 1) { 384 mtrace("- Received multiple senders. Only the first sender will be used."); 385 } 386 $sender = array_shift($senders); 387 388 mtrace("-- Subject:\t" . $envelope->subject); 389 mtrace("-- From:\t" . $sender); 390 mtrace("-- Recipient:\t" . $recipient); 391 392 // Grab messagedata including flags. 393 $query = new \Horde_Imap_Client_Fetch_Query(); 394 $query->structure(); 395 $messagedata = $this->client->fetch($this->get_mailbox(), $query, array( 396 'ids' => $messageid, 397 ))->first(); 398 399 if (!$viewreadmessages && $this->message_has_flag($messageid, self::MESSAGE_SEEN)) { 400 // Something else has already seen this message. Skip it now. 401 mtrace("-- Skipping the message - it has been marked as seen - perhaps by another process."); 402 continue; 403 } 404 405 // Mark it as read to lock the message. 406 $this->add_flag_to_message($messageid, self::MESSAGE_SEEN); 407 408 // Now pass it through the Inbound Message processor. 409 $status = $this->addressmanager->process_envelope($recipient, $sender); 410 411 if (($status & ~ \core\message\inbound\address_manager::VALIDATION_DISABLED_HANDLER) !== $status) { 412 // The handler is disabled. 413 mtrace("-- Skipped message - Handler is disabled. Fail code {$status}"); 414 // In order to handle the user error, we need more information about the message being failed. 415 $this->process_message_data($envelope, $messagedata, $messageid); 416 $this->inform_user_of_error(get_string('handlerdisabled', 'tool_messageinbound', $this->currentmessagedata)); 417 return; 418 } 419 420 // Check the validation status early. No point processing garbage messages, but we do need to process it 421 // for some validation failure types. 422 if (!$this->passes_key_validation($status, $messageid)) { 423 // None of the above validation failures were found. Skip this message. 424 mtrace("-- Skipped message - it does not appear to relate to a Inbound Message pickup. Fail code {$status}"); 425 426 // Remove the seen flag from the message as there may be multiple recipients. 427 $this->remove_flag_from_message($messageid, self::MESSAGE_SEEN); 428 429 // Skip further processing for this recipient. 430 continue; 431 } 432 433 // Process the message as the user. 434 $user = $this->addressmanager->get_data()->user; 435 mtrace("-- Processing the message as user {$user->id} ({$user->username})."); 436 \core\cron::setup_user($user); 437 438 // Process and retrieve the message data for this message. 439 // This includes fetching the full content, as well as all headers, and attachments. 440 if (!$this->process_message_data($envelope, $messagedata, $messageid)) { 441 mtrace("--- Message could not be found on the server. Is another process removing messages?"); 442 return; 443 } 444 445 // When processing validation replies, we need to skip the sender verification phase as this has been 446 // manually completed. 447 if (!$skipsenderverification && $status !== 0) { 448 // Check the validation status for failure types which require confirmation. 449 // The validation result is tested in a bitwise operation. 450 mtrace("-- Message did not meet validation but is possibly recoverable. Fail code {$status}"); 451 // This is a recoverable error, but requires user input. 452 453 if ($this->handle_verification_failure($messageid, $recipient)) { 454 mtrace("--- Original message retained on mail server and confirmation message sent to user."); 455 } else { 456 mtrace("--- Invalid Recipient Handler - unable to save. Informing the user of the failure."); 457 $this->inform_user_of_error(get_string('invalidrecipientfinal', 'tool_messageinbound', $this->currentmessagedata)); 458 } 459 460 // Returning to normal cron user. 461 mtrace("-- Returning to the original user."); 462 \core\cron::setup_user($originaluser); 463 return; 464 } 465 466 // Add the content and attachment data. 467 mtrace("-- Validation completed. Fetching rest of message content."); 468 $this->process_message_data_body($messagedata, $messageid); 469 470 // The message processor throws exceptions upon failure. These must be caught and notifications sent to 471 // the user here. 472 try { 473 $result = $this->send_to_handler(); 474 } catch (\core\message\inbound\processing_failed_exception $e) { 475 // We know about these kinds of errors and they should result in the user being notified of the 476 // failure. Send the user a notification here. 477 $this->inform_user_of_error($e->getMessage()); 478 479 // Returning to normal cron user. 480 mtrace("-- Returning to the original user."); 481 \core\cron::setup_user($originaluser); 482 return; 483 } catch (\Exception $e) { 484 // An unknown error occurred. The user is not informed, but the administrator is. 485 mtrace("-- Message processing failed. An unexpected exception was thrown. Details follow."); 486 mtrace($e->getMessage()); 487 488 // Returning to normal cron user. 489 mtrace("-- Returning to the original user."); 490 \core\cron::setup_user($originaluser); 491 return; 492 } 493 494 if ($result) { 495 // Handle message cleanup. Messages are deleted once fully processed. 496 mtrace("-- Marking the message for removal."); 497 $this->add_flag_to_message($messageid, self::MESSAGE_DELETED); 498 } else { 499 mtrace("-- The Inbound Message processor did not return a success status. Skipping message removal."); 500 } 501 502 // Returning to normal cron user. 503 mtrace("-- Returning to the original user."); 504 \core\cron::setup_user($originaluser); 505 506 mtrace("-- Finished processing " . $message->getUid()); 507 508 // Skip the outer loop too. The message has already been processed and it could be possible for there to 509 // be two recipients in the envelope which match somehow. 510 return; 511 } 512 } 513 514 /** 515 * Process a message to retrieve it's header data without body and attachemnts. 516 * 517 * @param \Horde_Imap_Client_Data_Envelope $envelope The Envelope of the message 518 * @param \Horde_Imap_Client_Data_Fetch $basemessagedata The structure and part of the message body 519 * @param string|\Horde_Imap_Client_Ids $messageid The Hore message Uid 520 * @return \stdClass The current value of the messagedata 521 */ 522 private function process_message_data( 523 \Horde_Imap_Client_Data_Envelope $envelope, 524 \Horde_Imap_Client_Data_Fetch $basemessagedata, 525 $messageid) { 526 527 // Get the current mailbox. 528 $mailbox = $this->get_mailbox(); 529 530 // We need the structure at various points below. 531 $structure = $basemessagedata->getStructure(); 532 533 // Now fetch the rest of the message content. 534 $query = new \Horde_Imap_Client_Fetch_Query(); 535 $query->imapDate(); 536 537 // Fetch the message header. 538 $query->headerText(); 539 540 // Retrieve the message with the above components. 541 $messagedata = $this->client->fetch($mailbox, $query, array('ids' => $messageid))->first(); 542 543 if (!$messagedata) { 544 // Message was not found! Somehow it has been removed or is no longer returned. 545 return null; 546 } 547 548 // The message ID should always be in the first part. 549 $data = new \stdClass(); 550 $data->messageid = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Message-ID'); 551 $data->subject = $envelope->subject; 552 $data->timestamp = $messagedata->getImapDate()->__toString(); 553 $data->envelope = $envelope; 554 $data->data = $this->addressmanager->get_data(); 555 $data->headers = $messagedata->getHeaderText(); 556 557 $this->currentmessagedata = $data; 558 559 return $this->currentmessagedata; 560 } 561 562 /** 563 * Process a message again to add body and attachment data. 564 * 565 * @param \Horde_Imap_Client_Data_Fetch $basemessagedata The structure and part of the message body 566 * @param string|\Horde_Imap_Client_Ids $messageid The Hore message Uid 567 * @return \stdClass The current value of the messagedata 568 */ 569 private function process_message_data_body( 570 \Horde_Imap_Client_Data_Fetch $basemessagedata, 571 $messageid) { 572 global $CFG; 573 574 // Get the current mailbox. 575 $mailbox = $this->get_mailbox(); 576 577 // We need the structure at various points below. 578 $structure = $basemessagedata->getStructure(); 579 580 // Now fetch the rest of the message content. 581 $query = new \Horde_Imap_Client_Fetch_Query(); 582 $query->fullText(); 583 584 // Fetch all of the message parts too. 585 $typemap = $structure->contentTypeMap(); 586 foreach ($typemap as $part => $type) { 587 // The body of the part - attempt to decode it on the server. 588 $query->bodyPart($part, array( 589 'decode' => true, 590 'peek' => true, 591 )); 592 $query->bodyPartSize($part); 593 } 594 595 $messagedata = $this->client->fetch($mailbox, $query, array('ids' => $messageid))->first(); 596 597 // Store the data for this message. 598 $contentplain = ''; 599 $contenthtml = ''; 600 $attachments = array( 601 'inline' => array(), 602 'attachment' => array(), 603 ); 604 605 $plainpartid = $structure->findBody('plain'); 606 $htmlpartid = $structure->findBody('html'); 607 608 foreach ($typemap as $part => $type) { 609 // Get the message data from the body part, and combine it with the structure to give a fully-formed output. 610 $stream = $messagedata->getBodyPart($part, true); 611 $partdata = $structure->getPart($part); 612 $partdata->setContents($stream, array( 613 'usestream' => true, 614 )); 615 616 if ($part == $plainpartid) { 617 $contentplain = $this->process_message_part_body($messagedata, $partdata, $part); 618 619 } else if ($part == $htmlpartid) { 620 $contenthtml = $this->process_message_part_body($messagedata, $partdata, $part); 621 622 } else if ($filename = $partdata->getName($part)) { 623 if ($attachment = $this->process_message_part_attachment($messagedata, $partdata, $part, $filename)) { 624 // The disposition should be one of 'attachment', 'inline'. 625 // If an empty string is provided, default to 'attachment'. 626 $disposition = $partdata->getDisposition(); 627 $disposition = $disposition == 'inline' ? 'inline' : 'attachment'; 628 $attachments[$disposition][] = $attachment; 629 } 630 } 631 632 // We don't handle any of the other MIME content at this stage. 633 } 634 635 // The message ID should always be in the first part. 636 $this->currentmessagedata->plain = $contentplain; 637 $this->currentmessagedata->html = $contenthtml; 638 $this->currentmessagedata->attachments = $attachments; 639 640 return $this->currentmessagedata; 641 } 642 643 /** 644 * Process the messagedata and part data to extract the content of this part. 645 * 646 * @param \Horde_Imap_Client_Data_Fetch $messagedata The structure and part of the message body 647 * @param \Horde_Mime_Part $partdata The part data 648 * @param string $part The part ID 649 * @return string 650 */ 651 private function process_message_part_body($messagedata, $partdata, $part) { 652 // This is a content section for the main body. 653 654 // Get the string version of it. 655 $content = $messagedata->getBodyPart($part); 656 if (!$messagedata->getBodyPartDecode($part)) { 657 // Decode the content. 658 $partdata->setContents($content); 659 $content = $partdata->getContents(); 660 } 661 662 // Convert the text from the current encoding to UTF8. 663 $content = \core_text::convert($content, $partdata->getCharset()); 664 665 // Fix any invalid UTF8 characters. 666 // Note: XSS cleaning is not the responsibility of this code. It occurs immediately before display when 667 // format_text is called. 668 $content = clean_param($content, PARAM_RAW); 669 670 return $content; 671 } 672 673 /** 674 * Process a message again to add body and attachment data. 675 * 676 * @param \Horde_Imap_Client_Data_Fetch $messagedata The structure and part of the message body 677 * @param \Horde_Mime_Part $partdata The part data 678 * @param string $part The part ID. 679 * @param string $filename The filename of the attachment 680 * @return \stdClass 681 * @throws \core\message\inbound\processing_failed_exception If the attachment can't be saved to disk. 682 */ 683 private function process_message_part_attachment($messagedata, $partdata, $part, $filename) { 684 global $CFG; 685 686 // If a filename is present, assume that this part is an attachment. 687 $attachment = new \stdClass(); 688 $attachment->filename = $filename; 689 $attachment->type = $partdata->getType(); 690 $attachment->content = $partdata->getContents(); 691 $attachment->charset = $partdata->getCharset(); 692 $attachment->description = $partdata->getDescription(); 693 $attachment->contentid = $partdata->getContentId(); 694 $attachment->filesize = $partdata->getBytes(); 695 696 if (!empty($CFG->antiviruses)) { 697 mtrace("--> Attempting virus scan of '{$attachment->filename}'"); 698 // Perform a virus scan now. 699 try { 700 \core\antivirus\manager::scan_data($attachment->content); 701 } catch (\core\antivirus\scanner_exception $e) { 702 mtrace("--> A virus was found in the attachment '{$attachment->filename}'."); 703 $this->inform_attachment_virus(); 704 return; 705 } 706 } 707 708 return $attachment; 709 } 710 711 /** 712 * Check whether the key provided is valid. 713 * 714 * @param bool $status 715 * @param mixed $messageid The Hore message Uid 716 * @return bool 717 */ 718 private function passes_key_validation($status, $messageid) { 719 // The validation result is tested in a bitwise operation. 720 if (( 721 $status & ~ \core\message\inbound\address_manager::VALIDATION_SUCCESS 722 & ~ \core\message\inbound\address_manager::VALIDATION_UNKNOWN_DATAKEY 723 & ~ \core\message\inbound\address_manager::VALIDATION_EXPIRED_DATAKEY 724 & ~ \core\message\inbound\address_manager::VALIDATION_INVALID_HASH 725 & ~ \core\message\inbound\address_manager::VALIDATION_ADDRESS_MISMATCH) !== 0) { 726 727 // One of the above bits was found in the status - fail the validation. 728 return false; 729 } 730 return true; 731 } 732 733 /** 734 * Add the specified flag to the message. 735 * 736 * @param mixed $messageid 737 * @param string $flag The flag to add 738 */ 739 private function add_flag_to_message($messageid, $flag) { 740 // Get the current mailbox. 741 $mailbox = $this->get_mailbox(); 742 743 // Mark it as read to lock the message. 744 $this->client->store($mailbox, array( 745 'ids' => new \Horde_Imap_Client_Ids($messageid), 746 'add' => $flag, 747 )); 748 } 749 750 /** 751 * Remove the specified flag from the message. 752 * 753 * @param mixed $messageid 754 * @param string $flag The flag to remove 755 */ 756 private function remove_flag_from_message($messageid, $flag) { 757 // Get the current mailbox. 758 $mailbox = $this->get_mailbox(); 759 760 // Mark it as read to lock the message. 761 $this->client->store($mailbox, array( 762 'ids' => $messageid, 763 'delete' => $flag, 764 )); 765 } 766 767 /** 768 * Check whether the message has the specified flag 769 * 770 * @param mixed $messageid 771 * @param string $flag The flag to check 772 * @return bool 773 */ 774 private function message_has_flag($messageid, $flag) { 775 // Get the current mailbox. 776 $mailbox = $this->get_mailbox(); 777 778 // Grab messagedata including flags. 779 $query = new \Horde_Imap_Client_Fetch_Query(); 780 $query->flags(); 781 $query->structure(); 782 $messagedata = $this->client->fetch($mailbox, $query, array( 783 'ids' => $messageid, 784 ))->first(); 785 $flags = $messagedata->getFlags(); 786 787 return in_array($flag, $flags); 788 } 789 790 /** 791 * Ensure that all mailboxes exist. 792 */ 793 private function ensure_mailboxes_exist() { 794 795 $requiredmailboxes = array( 796 self::MAILBOX, 797 $this->get_confirmation_folder(), 798 ); 799 800 $existingmailboxes = $this->client->listMailboxes($requiredmailboxes); 801 foreach ($requiredmailboxes as $mailbox) { 802 if (isset($existingmailboxes[$mailbox])) { 803 // This mailbox was found. 804 continue; 805 } 806 807 mtrace("Unable to find the '{$mailbox}' mailbox - creating it."); 808 $this->client->createMailbox($mailbox); 809 } 810 } 811 812 /** 813 * Attempt to determine whether this message is a bulk message (e.g. automated reply). 814 * 815 * @param \Horde_Imap_Client_Data_Fetch $message The message to process 816 * @param string|\Horde_Imap_Client_Ids $messageid The Hore message Uid 817 * @return boolean 818 */ 819 private function is_bulk_message( 820 \Horde_Imap_Client_Data_Fetch $message, 821 $messageid) { 822 $query = new \Horde_Imap_Client_Fetch_Query(); 823 $query->headerText(array('peek' => true)); 824 825 $messagedata = $this->client->fetch($this->get_mailbox(), $query, array('ids' => $messageid))->first(); 826 827 // Assume that this message is not bulk to begin with. 828 $isbulk = false; 829 830 // An auto-reply may itself include the Bulk Precedence. 831 $precedence = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Precedence'); 832 $isbulk = $isbulk || strtolower($precedence ?? '') == 'bulk'; 833 834 // If the X-Autoreply header is set, and not 'no', then this is an automatic reply. 835 $autoreply = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('X-Autoreply'); 836 $isbulk = $isbulk || ($autoreply && $autoreply != 'no'); 837 838 // If the X-Autorespond header is set, and not 'no', then this is an automatic response. 839 $autorespond = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('X-Autorespond'); 840 $isbulk = $isbulk || ($autorespond && $autorespond != 'no'); 841 842 // If the Auto-Submitted header is set, and not 'no', then this is a non-human response. 843 $autosubmitted = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Auto-Submitted'); 844 $isbulk = $isbulk || ($autosubmitted && $autosubmitted != 'no'); 845 846 return $isbulk; 847 } 848 849 /** 850 * Send the message to the appropriate handler. 851 * 852 * @return bool 853 * @throws \core\message\inbound\processing_failed_exception if anything goes wrong. 854 */ 855 private function send_to_handler() { 856 try { 857 mtrace("--> Passing to Inbound Message handler {$this->addressmanager->get_handler()->classname}"); 858 if ($result = $this->addressmanager->handle_message($this->currentmessagedata)) { 859 $this->inform_user_of_success($this->currentmessagedata, $result); 860 // Request that this message be marked for deletion. 861 return true; 862 } 863 864 } catch (\core\message\inbound\processing_failed_exception $e) { 865 mtrace("-> The Inbound Message handler threw an exception. Unable to process this message. The user has been informed."); 866 mtrace("--> " . $e->getMessage()); 867 // Throw the exception again, with additional data. 868 $error = new \stdClass(); 869 $error->subject = $this->currentmessagedata->envelope->subject; 870 $error->message = $e->getMessage(); 871 throw new \core\message\inbound\processing_failed_exception('messageprocessingfailed', 'tool_messageinbound', $error); 872 873 } catch (\Exception $e) { 874 mtrace("-> The Inbound Message handler threw an exception. Unable to process this message. User informed."); 875 mtrace("--> " . $e->getMessage()); 876 // An unknown error occurred. Still inform the user but, this time do not include the specific 877 // message information. 878 $error = new \stdClass(); 879 $error->subject = $this->currentmessagedata->envelope->subject; 880 throw new \core\message\inbound\processing_failed_exception('messageprocessingfailedunknown', 881 'tool_messageinbound', $error); 882 883 } 884 885 // Something went wrong and the message was not handled well in the Inbound Message handler. 886 mtrace("-> The Inbound Message handler reported an error. The message may not have been processed."); 887 888 // It is the responsiblity of the handler to throw an appropriate exception if the message was not processed. 889 // Do not inform the user at this point. 890 return false; 891 } 892 893 /** 894 * Handle failure of sender verification. 895 * 896 * This will send a notification to the user identified in the Inbound Message address informing them that a message has been 897 * stored. The message includes a verification link and reply-to address which is handled by the 898 * invalid_recipient_handler. 899 * 900 * @param \Horde_Imap_Client_Ids $messageids 901 * @param string $recipient The message recipient 902 * @return bool 903 */ 904 private function handle_verification_failure( 905 \Horde_Imap_Client_Ids $messageids, 906 $recipient) { 907 global $DB, $USER; 908 909 if (!$messageid = $this->currentmessagedata->messageid) { 910 mtrace("---> Warning: Unable to determine the Message-ID of the message."); 911 return false; 912 } 913 914 // Move the message into a new mailbox. 915 $this->client->copy(self::MAILBOX, $this->get_confirmation_folder(), array( 916 'create' => true, 917 'ids' => $messageids, 918 'move' => true, 919 )); 920 921 // Store the data from the failed message in the associated table. 922 $record = new \stdClass(); 923 $record->messageid = $messageid; 924 $record->userid = $USER->id; 925 $record->address = $recipient; 926 $record->timecreated = time(); 927 $record->id = $DB->insert_record('messageinbound_messagelist', $record); 928 929 // Setup the Inbound Message generator for the invalid recipient handler. 930 $addressmanager = new \core\message\inbound\address_manager(); 931 $addressmanager->set_handler('\tool_messageinbound\message\inbound\invalid_recipient_handler'); 932 $addressmanager->set_data($record->id); 933 934 $eventdata = new \core\message\message(); 935 $eventdata->component = 'tool_messageinbound'; 936 $eventdata->name = 'invalidrecipienthandler'; 937 938 $userfrom = clone $USER; 939 $userfrom->customheaders = array(); 940 // Adding the In-Reply-To header ensures that it is seen as a reply. 941 $userfrom->customheaders[] = 'In-Reply-To: ' . $messageid; 942 943 // The message will be sent from the intended user. 944 $eventdata->courseid = SITEID; 945 $eventdata->userfrom = \core_user::get_noreply_user(); 946 $eventdata->userto = $USER; 947 $eventdata->subject = $this->get_reply_subject($this->currentmessagedata->envelope->subject); 948 $eventdata->fullmessage = get_string('invalidrecipientdescription', 'tool_messageinbound', $this->currentmessagedata); 949 $eventdata->fullmessageformat = FORMAT_PLAIN; 950 $eventdata->fullmessagehtml = get_string('invalidrecipientdescriptionhtml', 'tool_messageinbound', $this->currentmessagedata); 951 $eventdata->smallmessage = $eventdata->fullmessage; 952 $eventdata->notification = 1; 953 $eventdata->replyto = $addressmanager->generate($USER->id); 954 955 mtrace("--> Sending a message to the user to report an verification failure."); 956 if (!message_send($eventdata)) { 957 mtrace("---> Warning: Message could not be sent."); 958 return false; 959 } 960 961 return true; 962 } 963 964 /** 965 * Inform the identified sender of a processing error. 966 * 967 * @param string $error The error message 968 */ 969 private function inform_user_of_error($error) { 970 global $USER; 971 972 // The message will be sent from the intended user. 973 $userfrom = clone $USER; 974 $userfrom->customheaders = array(); 975 976 if ($messageid = $this->currentmessagedata->messageid) { 977 // Adding the In-Reply-To header ensures that it is seen as a reply and threading is maintained. 978 $userfrom->customheaders[] = 'In-Reply-To: ' . $messageid; 979 } 980 981 $messagedata = new \stdClass(); 982 $messagedata->subject = $this->currentmessagedata->envelope->subject; 983 $messagedata->error = $error; 984 985 $eventdata = new \core\message\message(); 986 $eventdata->courseid = SITEID; 987 $eventdata->component = 'tool_messageinbound'; 988 $eventdata->name = 'messageprocessingerror'; 989 $eventdata->userfrom = $userfrom; 990 $eventdata->userto = $USER; 991 $eventdata->subject = self::get_reply_subject($this->currentmessagedata->envelope->subject); 992 $eventdata->fullmessage = get_string('messageprocessingerror', 'tool_messageinbound', $messagedata); 993 $eventdata->fullmessageformat = FORMAT_PLAIN; 994 $eventdata->fullmessagehtml = get_string('messageprocessingerrorhtml', 'tool_messageinbound', $messagedata); 995 $eventdata->smallmessage = $eventdata->fullmessage; 996 $eventdata->notification = 1; 997 998 if (message_send($eventdata)) { 999 mtrace("---> Notification sent to {$USER->email}."); 1000 } else { 1001 mtrace("---> Unable to send notification."); 1002 } 1003 } 1004 1005 /** 1006 * Inform the identified sender that message processing was successful. 1007 * 1008 * @param \stdClass $messagedata The data for the current message being processed. 1009 * @param mixed $handlerresult The result returned by the handler. 1010 * @return bool 1011 */ 1012 private function inform_user_of_success(\stdClass $messagedata, $handlerresult) { 1013 global $USER; 1014 1015 // Check whether the handler has a success notification. 1016 $handler = $this->addressmanager->get_handler(); 1017 $message = $handler->get_success_message($messagedata, $handlerresult); 1018 1019 if (!$message) { 1020 mtrace("---> Handler has not defined a success notification e-mail."); 1021 return false; 1022 } 1023 1024 // Wrap the message in the notification wrapper. 1025 $messageparams = new \stdClass(); 1026 $messageparams->html = $message->html; 1027 $messageparams->plain = $message->plain; 1028 $messagepreferencesurl = new \moodle_url("/message/notificationpreferences.php", array('id' => $USER->id)); 1029 $messageparams->messagepreferencesurl = $messagepreferencesurl->out(); 1030 $htmlmessage = get_string('messageprocessingsuccesshtml', 'tool_messageinbound', $messageparams); 1031 $plainmessage = get_string('messageprocessingsuccess', 'tool_messageinbound', $messageparams); 1032 1033 // The message will be sent from the intended user. 1034 $userfrom = clone $USER; 1035 $userfrom->customheaders = array(); 1036 1037 if ($messageid = $this->currentmessagedata->messageid) { 1038 // Adding the In-Reply-To header ensures that it is seen as a reply and threading is maintained. 1039 $userfrom->customheaders[] = 'In-Reply-To: ' . $messageid; 1040 } 1041 1042 $messagedata = new \stdClass(); 1043 $messagedata->subject = $this->currentmessagedata->envelope->subject; 1044 1045 $eventdata = new \core\message\message(); 1046 $eventdata->courseid = SITEID; 1047 $eventdata->component = 'tool_messageinbound'; 1048 $eventdata->name = 'messageprocessingsuccess'; 1049 $eventdata->userfrom = $userfrom; 1050 $eventdata->userto = $USER; 1051 $eventdata->subject = self::get_reply_subject($this->currentmessagedata->envelope->subject); 1052 $eventdata->fullmessage = $plainmessage; 1053 $eventdata->fullmessageformat = FORMAT_PLAIN; 1054 $eventdata->fullmessagehtml = $htmlmessage; 1055 $eventdata->smallmessage = $eventdata->fullmessage; 1056 $eventdata->notification = 1; 1057 1058 if (message_send($eventdata)) { 1059 mtrace("---> Success notification sent to {$USER->email}."); 1060 } else { 1061 mtrace("---> Unable to send success notification."); 1062 } 1063 return true; 1064 } 1065 1066 /** 1067 * Return a formatted subject line for replies. 1068 * 1069 * @param string $subject The subject string 1070 * @return string The formatted reply subject 1071 */ 1072 private function get_reply_subject($subject) { 1073 $prefix = get_string('replysubjectprefix', 'tool_messageinbound'); 1074 if (!(substr($subject, 0, strlen($prefix)) == $prefix)) { 1075 $subject = $prefix . ' ' . $subject; 1076 } 1077 1078 return $subject; 1079 } 1080 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body