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