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