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