Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403]

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