Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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  }