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