From a63d39b0ad81bcd25f0c9c0ba8d520c47beb17e5 Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Fri, 24 Nov 2023 22:08:02 +0700 Subject: [PATCH] MDL-80207 admin: Replace use of Horde with Roundcube --- admin/tool/messageinbound/classes/manager.php | 905 +++++++++++++++++--------- admin/tool/messageinbound/classes/utils.php | 56 ++ 2 files changed, 645 insertions(+), 316 deletions(-) create mode 100644 admin/tool/messageinbound/classes/utils.php diff --git a/admin/tool/messageinbound/classes/manager.php b/admin/tool/messageinbound/classes/manager.php index c42d87b462d..4fae00918c0 100644 --- a/admin/tool/messageinbound/classes/manager.php +++ b/admin/tool/messageinbound/classes/manager.php @@ -24,8 +24,6 @@ namespace tool_messageinbound; -defined('MOODLE_INTERNAL') || die(); - /** * Mail Pickup Manager. * @@ -65,7 +63,7 @@ class manager { protected $imapnamespace = null; /** - * @var \Horde_Imap_Client_Socket A reference to the IMAP client. + * @var \rcube_imap_generic A reference to the IMAP client. */ protected $client = null; @@ -80,11 +78,23 @@ class manager { protected $currentmessagedata = null; /** + * Mail Pickup Manager. + */ + public function __construct() { + // Load dependencies. + $this->load_dependencies(); + } + + /** * Retrieve the connection to the IMAP client. * + * @param string $mailbox The mailbox to connect to. + * * @return bool Whether a connection was successfully established. */ - protected function get_imap_client() { + protected function get_imap_client( + string $mailbox = self::MAILBOX, + ): bool { global $CFG; if (!\core\message\inbound\manager::is_enabled()) { @@ -95,65 +105,74 @@ class manager { mtrace("Connecting to {$CFG->messageinbound_host} as {$CFG->messageinbound_hostuser}..."); - $configuration = array( + $configuration = [ 'username' => $CFG->messageinbound_hostuser, 'password' => $CFG->messageinbound_hostpass, 'hostspec' => $CFG->messageinbound_host, - 'secure' => $CFG->messageinbound_hostssl, - 'debug' => empty($CFG->debugimap) ? null : fopen('php://stderr', 'w'), - ); + 'options' => [ + 'ssl_mode' => strtolower($CFG->messageinbound_hostssl), + 'auth_type' => 'CHECK', + ], + ]; if (strpos($configuration['hostspec'], ':')) { $hostdata = explode(':', $configuration['hostspec']); if (count($hostdata) === 2) { // A hostname in the format hostname:port has been provided. $configuration['hostspec'] = $hostdata[0]; - $configuration['port'] = $hostdata[1]; + $configuration['options']['port'] = $hostdata[1]; } } // XOAUTH2. - if ($CFG->messageinbound_hostoauth != '') { + if (isset($CFG->messageinbound_hostoauth) && $CFG->messageinbound_hostoauth != '') { // Get the issuer. $issuer = \core\oauth2\api::get_issuer($CFG->messageinbound_hostoauth); // Validate the issuer and check if it is enabled or not. if ($issuer && $issuer->get('enabled')) { // Get the OAuth Client. if ($oauthclient = \core\oauth2\api::get_system_oauth_client($issuer)) { - $xoauth2token = new \Horde_Imap_Client_Password_Xoauth2( - $configuration['username'], - $oauthclient->get_accesstoken()->token - ); - $configuration['xoauth2_token'] = $xoauth2token; - // Password is not necessary when using OAuth2 but Horde still needs it. We just set a random string here. - $configuration['password'] = random_string(64); + $configuration['password'] = 'Bearer ' . $oauthclient->get_accesstoken()->token; + $configuration['options']['auth_type'] = 'XOAUTH2'; } } } - $this->client = new \Horde_Imap_Client_Socket($configuration); + $this->client = new \rcube_imap_generic(); + if (!empty($CFG->debugimap)) { + $this->client->setDebug(debug: true); + } + $success = $this->client->connect( + host: $configuration['hostspec'], + user: $configuration['username'], + password: $configuration['password'], + options: $configuration['options'], + ); - try { - $this->client->login(); + if ($success) { mtrace("Connection established."); // Ensure that mailboxes exist. $this->ensure_mailboxes_exist(); - + // Select mailbox. + $this->client->select(mailbox: $mailbox); return true; - - } catch (\Horde_Imap_Client_Exception $e) { - $message = $e->getMessage(); - throw new \moodle_exception('imapconnectfailure', 'tool_messageinbound', '', null, $message); + } else { + throw new \moodle_exception('imapconnectfailure', 'tool_messageinbound', '', null, 'Could not connect to IMAP server.'); } } /** * Shutdown and close the connection to the IMAP client. */ - protected function close_connection() { + protected function close_connection(): void { if ($this->client) { - $this->client->close(); + // Close the connection and return to authenticated state. + $isclosed = $this->client->close(); + if ($isclosed) { + // Connection was closed unsuccessfully. Send the LOGOUT command and close the socket. + $this->client->closeConnection(); + } } $this->client = null; } @@ -163,12 +182,17 @@ class manager { * * @return string */ - protected function get_confirmation_folder() { - + protected function get_confirmation_folder(): string { if ($this->imapnamespace === null) { - if ($this->client->queryCapability('NAMESPACE')) { - $namespaces = $this->client->getNamespaces(array(), array('ob_return' => true)); - $this->imapnamespace = $namespaces->getNamespace('INBOX'); + $namespaces = $this->client->getNamespace(); + if ($namespaces != $this->client::ERROR_BAD && is_array($namespaces)) { + $nspersonal = reset($namespaces['personal']); + if (is_array($nspersonal) && !empty($nspersonal[0])) { + // Personal namespace is an array, the first part is the name, the second part is the delimiter. + $this->imapnamespace = $nspersonal[0] . $nspersonal[1]; + } else { + $this->imapnamespace = ''; + } } else { $this->imapnamespace = ''; } @@ -178,17 +202,15 @@ class manager { } /** - * Get the current mailbox information. + * Get the current mailbox name. * - * @return \Horde_Imap_Client_Mailbox + * @return string The current mailbox name. * @throws \core\message\inbound\processing_failed_exception if the mailbox could not be opened. */ - protected function get_mailbox() { + protected function get_mailbox(): string { // Get the current mailbox. - $mailbox = $this->client->currentMailbox(); - - if (isset($mailbox['mailbox'])) { - return $mailbox['mailbox']; + if ($this->client->selected) { + return $this->client->selected; } else { throw new \core\message\inbound\processing_failed_exception('couldnotopenmailbox', 'tool_messageinbound'); } @@ -199,30 +221,30 @@ class manager { * * @return bool */ - public function pickup_messages() { + public function pickup_messages(): bool { if (!$this->get_imap_client()) { return false; } // Restrict results to messages which are unseen, and have not been flagged. - $search = new \Horde_Imap_Client_Search_Query(); - $search->flag(self::MESSAGE_SEEN, false); - $search->flag(self::MESSAGE_FLAGGED, false); mtrace("Searching for Unseen, Unflagged email in the folder '" . self::MAILBOX . "'"); - $results = $this->client->search(self::MAILBOX, $search); + $result = $this->client->search( + mailbox: $this->get_mailbox(), + criteria: 'UNSEEN UNFLAGGED', + return_uid: true, + ); - // We require the envelope data and structure of each message. - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->envelope(); - $query->structure(); + if (empty($result->count())) { + return false; + } + mtrace("Found " . $result->count() . " messages to parse. Parsing..."); // Retrieve the message id. - $messages = $this->client->fetch(self::MAILBOX, $query, array('ids' => $results['match'])); - - mtrace("Found " . $messages->count() . " messages to parse. Parsing..."); + $messages = $result->get(); $this->addressmanager = new \core\message\inbound\address_manager(); - foreach ($messages as $message) { - $this->process_message($message); + foreach ($messages as $messageuid) { + $messageuid = is_numeric($messageuid) ? intval($messageuid) : $messageuid; + $this->process_message(messageuid: $messageuid); } // Close the client connection. @@ -238,39 +260,55 @@ class manager { * @return bool Whether the message was successfully processed. * @throws \core\message\inbound\processing_failed_exception if the message cannot be found. */ - public function process_existing_message(\stdClass $maildata) { + public function process_existing_message( + \stdClass $maildata, + ): bool { // Grab the new IMAP client. - if (!$this->get_imap_client()) { + if (!$this->get_imap_client(mailbox: $this->get_confirmation_folder())) { return false; } - // Build the search. - $search = new \Horde_Imap_Client_Search_Query(); // When dealing with Inbound Message messages, we mark them as flagged and seen. Restrict the search to those criterion. - $search->flag(self::MESSAGE_SEEN, true); - $search->flag(self::MESSAGE_FLAGGED, true); mtrace("Searching for a Seen, Flagged message in the folder '" . $this->get_confirmation_folder() . "'"); - // Match the message ID. - $search->headerText('message-id', $maildata->messageid); - $search->headerText('to', $maildata->address); - - $results = $this->client->search($this->get_confirmation_folder(), $search); - - // Build the base query. - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->envelope(); - $query->structure(); - + // Build the search. + $result = $this->client->search( + mailbox: $this->get_mailbox(), + criteria: 'SEEN FLAGGED TO "' . $maildata->address . '"', + return_uid: true, + ); - // Fetch the first message from the client. - $messages = $this->client->fetch($this->get_confirmation_folder(), $query, array('ids' => $results['match'])); $this->addressmanager = new \core\message\inbound\address_manager(); - if ($message = $messages->first()) { + if (!empty($result->count())) { + $messages = $result->get(); + $targetsequence = 0; + mtrace("Found " . $result->count() . " messages to parse. Parsing..."); + foreach ($messages as $messageuid) { + $messageuid = is_numeric($messageuid) ? intval($messageuid) : $messageuid; + $results = $this->client->fetch( + mailbox: $this->get_mailbox(), + message_set: $messageuid, + is_uid: true, + query_items: [ + 'BODY.PEEK[HEADER.FIELDS (Message-ID)]', + ], + ); + $messagedata = reset($results); + // Match the message id. + if (htmlentities($messagedata->get('Message-ID', false)) == $maildata->messageid) { + // Found the message. + $targetsequence = $messageuid; + break; + } + } mtrace("--> Found the message. Passing back to the pickup system."); // Process the message. - $this->process_message($message, true, true); + $this->process_message( + messageuid: $targetsequence, + viewreadmessages: true, + skipsenderverification: true, + ); // Close the client connection. $this->close_connection(); @@ -291,39 +329,46 @@ class manager { * * @return bool Whether tidying occurred successfully. */ - public function tidy_old_messages() { + public function tidy_old_messages(): bool { // Grab the new IMAP client. - if (!$this->get_imap_client()) { + if (!$this->get_imap_client(mailbox: $this->get_confirmation_folder())) { return false; } // Open the mailbox. mtrace("Searching for messages older than 24 hours in the '" . $this->get_confirmation_folder() . "' folder."); - $this->client->openMailbox($this->get_confirmation_folder()); - - $mailbox = $this->get_mailbox(); - - // Build the search. - $search = new \Horde_Imap_Client_Search_Query(); // Delete messages older than 24 hours old. - $search->intervalSearch(DAYSECS, \Horde_Imap_Client_Search_Query::INTERVAL_OLDER); + $date = date( + format: 'Y-m-d', + timestamp: time() - DAYSECS + ); - $results = $this->client->search($mailbox, $search); + // Retrieve the messages and mark them for removal. + $result = $this->client->search( + mailbox: $this->get_mailbox(), + criteria: 'BEFORE "' . $date . '"', + return_uid: true, + ); - // Build the base query. - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->envelope(); + if (empty($result->count())) { + $this->close_connection(); + return false; + } - // Retrieve the messages and mark them for removal. - $messages = $this->client->fetch($mailbox, $query, array('ids' => $results['match'])); - mtrace("Found " . $messages->count() . " messages for removal."); - foreach ($messages as $message) { - $this->add_flag_to_message($message->getUid(), self::MESSAGE_DELETED); + mtrace("Found " . $result->count() . " messages for removal."); + $messages = $result->get(); + foreach ($messages as $messageuid) { + $messageuid = is_numeric($messageuid) ? intval($messageuid) : $messageuid; + $this->add_flag_to_message( + messageuid: $messageuid, + flag: self::MESSAGE_DELETED + ); } mtrace("Finished removing messages."); + $this->close_connection(); return true; @@ -342,26 +387,27 @@ class manager { /** * Process a message and pass it through the Inbound Message handling systems. * - * @param \Horde_Imap_Client_Data_Fetch $message The message to process + * @param int $messageuid The message uid to process * @param bool $viewreadmessages Whether to also look at messages which have been marked as read * @param bool $skipsenderverification Whether to skip the sender verification stage */ public function process_message( - \Horde_Imap_Client_Data_Fetch $message, - $viewreadmessages = false, - $skipsenderverification = false) { + int $messageuid, + bool $viewreadmessages = false, + bool $skipsenderverification = false, + ): void { global $USER; - // We use the Client IDs several times - store them here. - $messageid = new \Horde_Imap_Client_Ids($message->getUid()); - - mtrace("- Parsing message " . $messageid); + mtrace("- Parsing message " . $messageuid); // First flag this message to prevent another running hitting this message while we look at the headers. - $this->add_flag_to_message($messageid, self::MESSAGE_FLAGGED); + $this->add_flag_to_message( + messageuid: $messageuid, + flag: self::MESSAGE_FLAGGED, + ); - if ($this->is_bulk_message($message, $messageid)) { - mtrace("- The message has a bulk header set. This is likely an auto-generated reply - discarding."); + if ($this->is_bulk_message(messageuid: $messageuid)) { + mtrace("- The message " . $messageuid . " has a bulk header set. This is likely an auto-generated reply - discarding."); return; } @@ -369,8 +415,17 @@ class manager { // messages, as \core\cron::setup_user is called multiple times. $originaluser = $USER; - $envelope = $message->getEnvelope(); - $recipients = $envelope->to->bare_addresses; + $envelope = $this->client->fetch( + mailbox: $this->get_mailbox(), + message_set: $messageuid, + is_uid: true, + query_items: [ + 'BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO)]', + 'ENVELOPE', + ], + ); + $envelope = array_shift($envelope); + $recipients = $this->get_address_from_envelope(addresslist: $envelope->envelope[5]); foreach ($recipients as $recipient) { if (!\core\message\inbound\address_manager::is_correct_format($recipient)) { // Message did not contain a subaddress. @@ -379,7 +434,7 @@ class manager { } // Message contained a match. - $senders = $message->getEnvelope()->from->bare_addresses; + $senders = $this->get_address_from_envelope(addresslist: $envelope->envelope[2]); if (count($senders) !== 1) { mtrace("- Received multiple senders. Only the first sender will be used."); } @@ -389,21 +444,24 @@ class manager { mtrace("-- From:\t" . $sender); mtrace("-- Recipient:\t" . $recipient); - // Grab messagedata including flags. - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->structure(); - $messagedata = $this->client->fetch($this->get_mailbox(), $query, array( - 'ids' => $messageid, - ))->first(); - - if (!$viewreadmessages && $this->message_has_flag($messageid, self::MESSAGE_SEEN)) { + // Check whether this message has already been processed. + if ( + !$viewreadmessages && + $this->message_has_flag( + messageuid: $messageuid, + flag: self::MESSAGE_SEEN, + ) + ) { // Something else has already seen this message. Skip it now. mtrace("-- Skipping the message - it has been marked as seen - perhaps by another process."); continue; } // Mark it as read to lock the message. - $this->add_flag_to_message($messageid, self::MESSAGE_SEEN); + $this->add_flag_to_message( + messageuid: $messageuid, + flag: self::MESSAGE_SEEN, + ); // Now pass it through the Inbound Message processor. $status = $this->addressmanager->process_envelope($recipient, $sender); @@ -412,19 +470,25 @@ class manager { // The handler is disabled. mtrace("-- Skipped message - Handler is disabled. Fail code {$status}"); // In order to handle the user error, we need more information about the message being failed. - $this->process_message_data($envelope, $messagedata, $messageid); + $this->process_message_data( + envelope: $envelope, + messageuid: $messageuid, + ); $this->inform_user_of_error(get_string('handlerdisabled', 'tool_messageinbound', $this->currentmessagedata)); return; } // Check the validation status early. No point processing garbage messages, but we do need to process it // for some validation failure types. - if (!$this->passes_key_validation($status, $messageid)) { + if (!$this->passes_key_validation(status: $status)) { // None of the above validation failures were found. Skip this message. mtrace("-- Skipped message - it does not appear to relate to a Inbound Message pickup. Fail code {$status}"); // Remove the seen flag from the message as there may be multiple recipients. - $this->remove_flag_from_message($messageid, self::MESSAGE_SEEN); + $this->remove_flag_from_message( + messageuid: $messageuid, + flag: self::MESSAGE_SEEN, + ); // Skip further processing for this recipient. continue; @@ -437,7 +501,12 @@ class manager { // Process and retrieve the message data for this message. // This includes fetching the full content, as well as all headers, and attachments. - if (!$this->process_message_data($envelope, $messagedata, $messageid)) { + if ( + !$this->process_message_data( + envelope: $envelope, + messageuid: $messageuid, + ) + ) { mtrace("--- Message could not be found on the server. Is another process removing messages?"); return; } @@ -450,7 +519,12 @@ class manager { mtrace("-- Message did not meet validation but is possibly recoverable. Fail code {$status}"); // This is a recoverable error, but requires user input. - if ($this->handle_verification_failure($messageid, $recipient)) { + if ( + $this->handle_verification_failure( + messageuid: $messageuid, + recipient: $recipient, + ) + ) { mtrace("--- Original message retained on mail server and confirmation message sent to user."); } else { mtrace("--- Invalid Recipient Handler - unable to save. Informing the user of the failure."); @@ -465,7 +539,7 @@ class manager { // Add the content and attachment data. mtrace("-- Validation completed. Fetching rest of message content."); - $this->process_message_data_body($messagedata, $messageid); + $this->process_message_data_body(messageuid: $messageuid); // The message processor throws exceptions upon failure. These must be caught and notifications sent to // the user here. @@ -494,7 +568,10 @@ class manager { if ($result) { // Handle message cleanup. Messages are deleted once fully processed. mtrace("-- Marking the message for removal."); - $this->add_flag_to_message($messageid, self::MESSAGE_DELETED); + $this->add_flag_to_message( + messageuid: $messageuid, + flag: self::MESSAGE_DELETED + ); } else { mtrace("-- The Inbound Message processor did not return a success status. Skipping message removal."); } @@ -503,7 +580,7 @@ class manager { mtrace("-- Returning to the original user."); \core\cron::setup_user($originaluser); - mtrace("-- Finished processing " . $message->getUid()); + mtrace("-- Finished processing " . $messageuid); // Skip the outer loop too. The message has already been processed and it could be possible for there to // be two recipients in the envelope which match somehow. @@ -512,33 +589,26 @@ class manager { } /** - * Process a message to retrieve it's header data without body and attachemnts. + * Process a message to retrieve it's header data without body. * - * @param \Horde_Imap_Client_Data_Envelope $envelope The Envelope of the message - * @param \Horde_Imap_Client_Data_Fetch $basemessagedata The structure and part of the message body - * @param string|\Horde_Imap_Client_Ids $messageid The Hore message Uid - * @return \stdClass The current value of the messagedata + * @param \rcube_message_header $envelope The Envelope of the message + * @param int $messageuid The message Uid to process + * @return \stdClass|null The current value of the messagedata */ private function process_message_data( - \Horde_Imap_Client_Data_Envelope $envelope, - \Horde_Imap_Client_Data_Fetch $basemessagedata, - $messageid) { - - // Get the current mailbox. - $mailbox = $this->get_mailbox(); - - // We need the structure at various points below. - $structure = $basemessagedata->getStructure(); - - // Now fetch the rest of the message content. - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->imapDate(); - - // Fetch the message header. - $query->headerText(); - - // Retrieve the message with the above components. - $messagedata = $this->client->fetch($mailbox, $query, array('ids' => $messageid))->first(); + \rcube_message_header $envelope, + int $messageuid, + ): ?\stdClass { + // Retrieve the message with necessary information. + $messages = $this->client->fetch( + mailbox: $this->get_mailbox(), + message_set: $messageuid, + is_uid: true, + query_items: [ + 'BODY.PEEK[HEADER.FIELDS (Message-ID SUBJECT DATE)]', + ], + ); + $messagedata = reset($messages); if (!$messagedata) { // Message was not found! Somehow it has been removed or is no longer returned. @@ -547,12 +617,11 @@ class manager { // The message ID should always be in the first part. $data = new \stdClass(); - $data->messageid = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Message-ID'); - $data->subject = $envelope->subject; - $data->timestamp = $messagedata->getImapDate()->__toString(); + $data->messageid = htmlentities($messagedata->get('Message-ID', false)); + $data->subject = $messagedata->get('SUBJECT', false); + $data->timestamp = strtotime($messagedata->get('DATE', false)); $data->envelope = $envelope; $data->data = $this->addressmanager->get_data(); - $data->headers = $messagedata->getHeaderText(); $this->currentmessagedata = $data; @@ -562,105 +631,237 @@ class manager { /** * Process a message again to add body and attachment data. * - * @param \Horde_Imap_Client_Data_Fetch $basemessagedata The structure and part of the message body - * @param string|\Horde_Imap_Client_Ids $messageid The Hore message Uid - * @return \stdClass The current value of the messagedata + * @param int $messageuid The message Uid + * @return \stdClass|null The current value of the messagedata */ private function process_message_data_body( - \Horde_Imap_Client_Data_Fetch $basemessagedata, - $messageid) { - global $CFG; - - // Get the current mailbox. - $mailbox = $this->get_mailbox(); - - // We need the structure at various points below. - $structure = $basemessagedata->getStructure(); - - // Now fetch the rest of the message content. - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->fullText(); - - // Fetch all of the message parts too. - $typemap = $structure->contentTypeMap(); - foreach ($typemap as $part => $type) { - // The body of the part - attempt to decode it on the server. - $query->bodyPart($part, array( - 'decode' => true, - 'peek' => true, - )); - $query->bodyPartSize($part); - } - - $messagedata = $this->client->fetch($mailbox, $query, array('ids' => $messageid))->first(); + int $messageuid, + ): ?\stdClass { + $messages = $this->client->fetch( + mailbox: $this->get_mailbox(), + message_set: $messageuid, + is_uid: true, + query_items: [ + 'BODYSTRUCTURE', + ], + ); + $messagedata = reset($messages); + $structure = $messagedata->bodystructure; // Store the data for this message. $contentplain = ''; $contenthtml = ''; - $attachments = array( - 'inline' => array(), - 'attachment' => array(), + $attachments = [ + 'inline' => [], + 'attachment' => [], + ]; + $parameters = []; + foreach ($structure as $partno => $part) { + if (!is_array($part)) { + continue; + } + $section = $partno + 1; + + // Subpart recursion. + if (is_array($part[0])) { + foreach ($part as $subpartno => $subpart) { + if (!is_array($subpart)) { + continue; + } + $subsection = $subpartno + 1; + $this->process_message_data_body_part( + messageuid: $messageuid, + partstructure: $subpart, + section: $section . '.' . $subsection, + contentplain: $contentplain, + contenthtml: $contenthtml, + attachments: $attachments, + parameters: $parameters, + ); + } + } else { + $this->process_message_data_body_part( + messageuid: $messageuid, + partstructure: $part, + section: $section, + contentplain: $contentplain, + contenthtml: $contenthtml, + attachments: $attachments, + parameters: $parameters, + ); + } + } + + // The message ID should always be in the first part. + $this->currentmessagedata->plain = $contentplain; + $this->currentmessagedata->html = $contenthtml; + $this->currentmessagedata->attachments = $attachments; + + return $this->currentmessagedata; + } + + /** + * Process message data body part. + * + * @param int $messageuid Message uid to process. + * @param array $partstructure Body part structure. + * @param string $section Section number. + * @param string $contentplain Plain text content. + * @param string $contenthtml HTML content. + * @param array $attachments Attachments. + * @param array $parameters Parameters. + */ + private function process_message_data_body_part( + int $messageuid, + array $partstructure, + string $section, + string &$contentplain, + string &$contenthtml, + array &$attachments, + array &$parameters, + ): void { + $messages = $this->client->fetch( + mailbox: $this->get_mailbox(), + message_set: $messageuid, + is_uid: true, + query_items: [ + 'BODY[' . $section . ']', + ], ); + if ($messages) { + $messagedata = reset($messages); + + // Parse encoding. + $encoding = array_search( + needle: strtoupper($partstructure[5]), + haystack: utils::get_body_encoding(), + ); + + // Parse subtype. + $subtype = strtoupper($partstructure[1]); + + // Section part may be encoded, even plain text messages, so check everything. + if ($encoding == utils::ENCQUOTEDPRINTABLE) { + $data = quoted_printable_decode($messagedata->bodypart[$section]); + } else if ($encoding == utils::ENCBASE64) { + $data = base64_decode($messagedata->bodypart[$section]); + } else { + $data = $messagedata->bodypart[$section]; + } - $plainpartid = $structure->findBody('plain'); - $htmlpartid = $structure->findBody('html'); + // Parse parameters. + $parameters = $this->process_message_body_structure_parameters( + attributes: $partstructure[2], + parameters: $parameters, + ); - foreach ($typemap as $part => $type) { - // Get the message data from the body part, and combine it with the structure to give a fully-formed output. - $stream = $messagedata->getBodyPart($part, true); - $partdata = $structure->getPart($part); - $partdata->setContents($stream, array( - 'usestream' => true, - )); + // Parse content id. + $contentid = ''; + if (!empty($partstructure[3])) { + $contentid = htmlentities($partstructure[3]); + } - if ($part == $plainpartid) { - $contentplain = $this->process_message_part_body($messagedata, $partdata, $part); + // Parse description. + $description = ''; + if (!empty($partstructure[4])) { + $description = $partstructure[4]; + } - } else if ($part == $htmlpartid) { - $contenthtml = $this->process_message_part_body($messagedata, $partdata, $part); + // Parse size of contents in bytes. + $bytes = intval($partstructure[6]); - } else if ($filename = $partdata->getName($part)) { - if ($attachment = $this->process_message_part_attachment($messagedata, $partdata, $part, $filename)) { - // The disposition should be one of 'attachment', 'inline'. - // If an empty string is provided, default to 'attachment'. - $disposition = $partdata->getDisposition(); + // PLAIN text. + if ($subtype == 'PLAIN') { + $contentplain = $this->process_message_part_body( + bodycontent: $data, + charset: $parameters['CHARSET'], + ); + } + // HTML. + if ($subtype == 'HTML') { + $contenthtml = $this->process_message_part_body( + bodycontent: $data, + charset: $parameters['CHARSET'], + ); + } + // ATTACHMENT. + if (isset($parameters['NAME']) || isset($parameters['FILENAME'])) { + $filename = $parameters['NAME'] ?? $parameters['FILENAME']; + if ( + $attachment = $this->process_message_part_attachment( + filename: $filename, + filecontent: $data, + contentid: $contentid, + filesize: $bytes, + description: $description, + ) + ) { + // Parse disposition. + $disposition = null; + if (is_array($partstructure[8])) { + $disposition = strtolower($partstructure[8][0]); + } $disposition = $disposition == 'inline' ? 'inline' : 'attachment'; $attachments[$disposition][] = $attachment; } } + } + } - // We don't handle any of the other MIME content at this stage. + /** + * Process message data body parameters. + * + * @param array $attributes List of attributes. + * @param array $parameters List of parameters. + * @return array + */ + private function process_message_body_structure_parameters( + array $attributes, + array $parameters, + ): array { + if (empty($attributes)) { + return []; } - // The message ID should always be in the first part. - $this->currentmessagedata->plain = $contentplain; - $this->currentmessagedata->html = $contenthtml; - $this->currentmessagedata->attachments = $attachments; + $attribute = null; - return $this->currentmessagedata; + foreach ($attributes as $value) { + if (empty($attribute)) { + $attribute = [ + 'attribute' => $value, + 'value' => null, + ]; + } else { + $attribute['value'] = $value; + $parameters[] = (object) $attribute; + $attribute = null; + } + } + + $params = []; + foreach ($parameters as $parameter) { + if (isset($parameter->attribute)) { + $params[$parameter->attribute] = $parameter->value; + } + } + + return $params; } /** - * Process the messagedata and part data to extract the content of this part. + * Process the message body content. * - * @param \Horde_Imap_Client_Data_Fetch $messagedata The structure and part of the message body - * @param \Horde_Mime_Part $partdata The part data - * @param string $part The part ID - * @return string + * @param string $bodycontent The message body. + * @param string $charset The charset of the message body. + * @return string Processed content. */ - private function process_message_part_body($messagedata, $partdata, $part) { + private function process_message_part_body( + string $bodycontent, + string $charset, + ): string { // This is a content section for the main body. - - // Get the string version of it. - $content = $messagedata->getBodyPart($part); - if (!$messagedata->getBodyPartDecode($part)) { - // Decode the content. - $partdata->setContents($content); - $content = $partdata->getContents(); - } - // Convert the text from the current encoding to UTF8. - $content = \core_text::convert($content, $partdata->getCharset()); + $content = \core_text::convert($bodycontent, $charset); // Fix any invalid UTF8 characters. // Note: XSS cleaning is not the responsibility of this code. It occurs immediately before display when @@ -673,36 +874,32 @@ class manager { /** * Process a message again to add body and attachment data. * - * @param \Horde_Imap_Client_Data_Fetch $messagedata The structure and part of the message body - * @param \Horde_Mime_Part $partdata The part data - * @param string $part The part ID. - * @param string $filename The filename of the attachment + * @param string $filename The filename of the attachment. + * @param string $filecontent The content of the attachment. + * @param string $contentid The content id of the attachment. + * @param int $filesize The size of the attachment. + * @param string $description The description of the attachment. * @return \stdClass - * @throws \core\message\inbound\processing_failed_exception If the attachment can't be saved to disk. */ - private function process_message_part_attachment($messagedata, $partdata, $part, $filename) { + private function process_message_part_attachment( + string $filename, + string $filecontent, + string $contentid, + int $filesize, + string $description = '', + ): \stdClass { global $CFG; // If a filename is present, assume that this part is an attachment. $attachment = new \stdClass(); - $attachment->filename = $filename; - $attachment->type = $partdata->getType(); - $attachment->content = $partdata->getContents(); - $attachment->charset = $partdata->getCharset(); - $attachment->description = $partdata->getDescription(); - $attachment->contentid = $partdata->getContentId(); - $attachment->filesize = $partdata->getBytes(); + $attachment->filename = $filename; + $attachment->content = $filecontent; + $attachment->description = $description; + $attachment->contentid = $contentid; + $attachment->filesize = $filesize; if (!empty($CFG->antiviruses)) { - mtrace("--> Attempting virus scan of '{$attachment->filename}'"); - // Perform a virus scan now. - try { - \core\antivirus\manager::scan_data($attachment->content); - } catch (\core\antivirus\scanner_exception $e) { - mtrace("--> A virus was found in the attachment '{$attachment->filename}'."); - $this->inform_attachment_virus(); - return; - } + // Virus scanning is removed and will be brought back by MDL-50434. } return $attachment; @@ -711,11 +908,12 @@ class manager { /** * Check whether the key provided is valid. * - * @param bool $status - * @param mixed $messageid The Hore message Uid + * @param int $status The status to validate. * @return bool */ - private function passes_key_validation($status, $messageid) { + private function passes_key_validation( + int $status, + ): bool { // The validation result is tested in a bitwise operation. if (( $status & ~ \core\message\inbound\address_manager::VALIDATION_SUCCESS @@ -733,114 +931,128 @@ class manager { /** * Add the specified flag to the message. * - * @param mixed $messageid + * @param int $messageuid Message uid to process * @param string $flag The flag to add */ - private function add_flag_to_message($messageid, $flag) { - // Get the current mailbox. - $mailbox = $this->get_mailbox(); - - // Mark it as read to lock the message. - $this->client->store($mailbox, array( - 'ids' => new \Horde_Imap_Client_Ids($messageid), - 'add' => $flag, - )); + private function add_flag_to_message( + int $messageuid, + string $flag, + ): void { + // Add flag to the message. + $this->client->flag( + mailbox: $this->get_mailbox(), + messages: $messageuid, + flag: strtoupper(substr($flag, 1)), + ); } /** * Remove the specified flag from the message. * - * @param mixed $messageid + * @param int $messageuid Message uid to process * @param string $flag The flag to remove */ - private function remove_flag_from_message($messageid, $flag) { - // Get the current mailbox. - $mailbox = $this->get_mailbox(); - - // Mark it as read to lock the message. - $this->client->store($mailbox, array( - 'ids' => $messageid, - 'delete' => $flag, - )); + private function remove_flag_from_message( + int $messageuid, + string $flag, + ): void { + // Remove the flag from the message. + $this->client->unflag( + mailbox: $this->get_mailbox(), + messages: $messageuid, + flag: strtoupper(substr($flag, 1)), + ); } /** * Check whether the message has the specified flag * - * @param mixed $messageid - * @param string $flag The flag to check - * @return bool + * @param int $messageuid Message uid to check. + * @param string $flag The flag to check. + * @return bool True if the message has the flag, false otherwise. */ - private function message_has_flag($messageid, $flag) { - // Get the current mailbox. - $mailbox = $this->get_mailbox(); - - // Grab messagedata including flags. - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->flags(); - $query->structure(); - $messagedata = $this->client->fetch($mailbox, $query, array( - 'ids' => $messageid, - ))->first(); - $flags = $messagedata->getFlags(); - - return in_array($flag, $flags); + private function message_has_flag( + int $messageuid, + string $flag, + ): bool { + // Grab the message data with flags. + $messages = $this->client->fetch( + mailbox: $this->get_mailbox(), + message_set: $messageuid, + is_uid: true, + query_items: [ + 'FLAGS', + ], + ); + $messagedata = reset($messages); + $flags = $messagedata->flags; + return array_key_exists( + key: strtoupper(substr($flag, 1)), + array: $flags, + ); } /** * Ensure that all mailboxes exist. */ - private function ensure_mailboxes_exist() { - - $requiredmailboxes = array( + private function ensure_mailboxes_exist(): void { + $requiredmailboxes = [ self::MAILBOX, $this->get_confirmation_folder(), - ); + ]; - $existingmailboxes = $this->client->listMailboxes($requiredmailboxes); + $existingmailboxes = $this->client->listMailboxes( + ref: '', + mailbox: '*', + ); foreach ($requiredmailboxes as $mailbox) { - if (isset($existingmailboxes[$mailbox])) { + if (in_array($mailbox, $existingmailboxes)) { // This mailbox was found. continue; } mtrace("Unable to find the '{$mailbox}' mailbox - creating it."); - $this->client->createMailbox($mailbox); + $this->client->createFolder( + mailbox: $mailbox, + ); } } /** * Attempt to determine whether this message is a bulk message (e.g. automated reply). * - * @param \Horde_Imap_Client_Data_Fetch $message The message to process - * @param string|\Horde_Imap_Client_Ids $messageid The Hore message Uid + * @param int $messageuid The message uid to check * @return boolean */ private function is_bulk_message( - \Horde_Imap_Client_Data_Fetch $message, - $messageid) { - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->headerText(array('peek' => true)); - - $messagedata = $this->client->fetch($this->get_mailbox(), $query, array('ids' => $messageid))->first(); - + int $messageuid, + ): bool { + $messages = $this->client->fetch( + mailbox: $this->get_mailbox(), + message_set: $messageuid, + is_uid: true, + query_items: [ + 'BODY.PEEK[HEADER.FIELDS (Precedence X-Autoreply X-Autorespond Auto-Submitted)]', + ], + ); + $headerinfo = reset($messages); // Assume that this message is not bulk to begin with. $isbulk = false; // An auto-reply may itself include the Bulk Precedence. - $precedence = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Precedence'); + $precedence = $headerinfo->get('Precedence', false); $isbulk = $isbulk || strtolower($precedence ?? '') == 'bulk'; // If the X-Autoreply header is set, and not 'no', then this is an automatic reply. - $autoreply = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('X-Autoreply'); + $autoreply = $headerinfo->get('X-Autoreply', false); $isbulk = $isbulk || ($autoreply && $autoreply != 'no'); // If the X-Autorespond header is set, and not 'no', then this is an automatic response. - $autorespond = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('X-Autorespond'); + $autorespond = $headerinfo->get('X-Autorespond', false); $isbulk = $isbulk || ($autorespond && $autorespond != 'no'); // If the Auto-Submitted header is set, and not 'no', then this is a non-human response. - $autosubmitted = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Auto-Submitted'); + $autosubmitted = $headerinfo->get('Auto-Submitted', false); $isbulk = $isbulk || ($autosubmitted && $autosubmitted != 'no'); return $isbulk; @@ -897,30 +1109,32 @@ class manager { * stored. The message includes a verification link and reply-to address which is handled by the * invalid_recipient_handler. * - * @param \Horde_Imap_Client_Ids $messageids + * @param int $messageuid The message uid to process. * @param string $recipient The message recipient * @return bool */ private function handle_verification_failure( - \Horde_Imap_Client_Ids $messageids, - $recipient) { + int $messageuid, + string $recipient, + ): bool { global $DB, $USER; - if (!$messageid = $this->currentmessagedata->messageid) { + $messageid = $this->get_message_sequence_from_uid($messageuid); + if ($messageid == $this->currentmessagedata->messageid) { mtrace("---> Warning: Unable to determine the Message-ID of the message."); return false; } // Move the message into a new mailbox. - $this->client->copy(self::MAILBOX, $this->get_confirmation_folder(), array( - 'create' => true, - 'ids' => $messageids, - 'move' => true, - )); + $this->client->move( + messages: $messageuid, + from: $this->get_mailbox(), + to: $this->get_confirmation_folder(), + ); // Store the data from the failed message in the associated table. $record = new \stdClass(); - $record->messageid = $messageid; + $record->messageid = $messageuid; $record->userid = $USER->id; $record->address = $recipient; $record->timecreated = time(); @@ -938,7 +1152,7 @@ class manager { $userfrom = clone $USER; $userfrom->customheaders = array(); // Adding the In-Reply-To header ensures that it is seen as a reply. - $userfrom->customheaders[] = 'In-Reply-To: ' . $messageid; + $userfrom->customheaders[] = 'In-Reply-To: ' . $messageuid; // The message will be sent from the intended user. $eventdata->courseid = SITEID; @@ -1077,4 +1291,63 @@ class manager { return $subject; } + + /** + * Parse the address from the envelope. + * + * @param array $addresslist List of email addresses to parse. + * @return array|null List of parsed email addresses. + */ + protected function get_address_from_envelope(array $addresslist): array|null { + if (empty($addresslist)) { + return null; + } + + $parsedaddressentry = []; + foreach ($addresslist as $addressentry) { + $parsedaddressentry[] = "{$addressentry[2]}@{$addressentry[3]}"; + } + + return $parsedaddressentry; + } + + /** + * Get the message sequence number from the message uid. + * + * @param int $messageuid The message uid to process. + * @return int The message sequence number. + */ + protected function get_message_sequence_from_uid( + int $messageuid, + ): int { + $messages = $this->client->fetch( + mailbox: $this->get_mailbox(), + message_set: $messageuid, + is_uid: true, + query_items: [ + 'SEQUENCE', + ], + ); + $messagedata = reset($messages); + return $messagedata->sequence; + } + + /** + * We use Roundcube Framework to receive the emails. + * This method will load the required dependencies. + */ + protected function load_dependencies(): void { + global $CFG; + $dependencies = [ + 'rcube_charset.php', + 'rcube_imap_generic.php', + 'rcube_message_header.php', + 'rcube_mime.php', + 'rcube_result_index.php', + 'rcube_result_thread.php', + 'rcube_utils.php', + ]; + + array_map(fn($file) => require_once("$CFG->dirroot/$CFG->admin/tool/messageinbound/roundcube/{$file}"), $dependencies); + } } diff --git a/admin/tool/messageinbound/classes/utils.php b/admin/tool/messageinbound/classes/utils.php new file mode 100644 index 00000000000..2caf1d6efe0 --- /dev/null +++ b/admin/tool/messageinbound/classes/utils.php @@ -0,0 +1,56 @@ +. + +namespace tool_messageinbound; + +/** + * The Mail Pickup Utils. + * + * @package tool_messageinbound + * @copyright 2023 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class utils { + + /** @var int Encoding type: 7 bit SMTP semantic data. */ + const ENC7BIT = 0; + /** @var int Encoding type: 8 bit SMTP semantic data. */ + const ENC8BIT = 1; + /** @var int Encoding type: 8 bit binary data. */ + const ENCBINARY = 2; + /** @var int Encoding type: BASE64 encoded data. */ + const ENCBASE64 = 3; + /** @var int Encoding type: Human-readable 8-as-7 bit data. */ + const ENCQUOTEDPRINTABLE = 4; + /** @var int Encoding type: Unknown. */ + const ENCOTHER = 5; + + /** + * Get body content encoding. + * + * @return string[] List of body content encoding. + */ + public static function get_body_encoding(): array { + return [ + self::ENC7BIT => '7BIT', + self::ENC8BIT => '8BIT', + self::ENCBINARY => 'BINARY', + self::ENCBASE64 => 'BASE64', + self::ENCQUOTEDPRINTABLE => 'QUOTED-PRINTABLE', + self::ENCOTHER => 'X-UNKNOWN', + ]; + } +} -- 2.11.4.GIT