MDL-53262 inboundmessage: Fix inconsistent confirm From vs email body
[moodle.git] / admin / tool / messageinbound / classes / manager.php
CommitLineData
77c0a68d
AN
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 /**
365c4edc 63 * @var \Horde_Imap_Client_Socket A reference to the IMAP client.
77c0a68d
AN
64 */
65 protected $client = null;
66
67 /**
68 * @var \core\message\inbound\address_manager A reference to the Inbound Message Address Manager instance.
69 */
70 protected $addressmanager = null;
71
72 /**
365c4edc 73 * @var \stdClass The data for the current message being processed.
77c0a68d
AN
74 */
75 protected $currentmessagedata = null;
76
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;
84
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 }
90
91 mtrace("Connecting to {$CFG->messageinbound_host} as {$CFG->messageinbound_hostuser}...");
92
93 $configuration = array(
94 'username' => $CFG->messageinbound_hostuser,
95 'password' => $CFG->messageinbound_hostpass,
96 'hostspec' => $CFG->messageinbound_host,
97 'secure' => $CFG->messageinbound_hostssl,
98 );
99
100 $this->client = new \Horde_Imap_Client_Socket($configuration);
101
102 try {
103 $this->client->login();
104 mtrace("Connection established.");
fd424b99
AN
105
106 // Ensure that mailboxes exist.
107 $this->ensure_mailboxes_exist();
108
77c0a68d
AN
109 return true;
110
111 } catch (\Horde_Imap_Client_Exception $e) {
112 $message = $e->getMessage();
113 mtrace("Unable to connect to IMAP server. Failed with '{$message}'");
114
115 return false;
116 }
117 }
118
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 }
128
129 /**
130 * Get the current mailbox information.
131 *
132 * @return \Horde_Imap_Client_Mailbox
365c4edc 133 * @throws \core\message\inbound\processing_failed_exception if the mailbox could not be opened.
77c0a68d
AN
134 */
135 protected function get_mailbox() {
136 // Get the current mailbox.
137 $mailbox = $this->client->currentMailbox();
138
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 }
145
146 /**
147 * Execute the main Inbound Message pickup task.
365c4edc
SH
148 *
149 * @return bool
77c0a68d
AN
150 */
151 public function pickup_messages() {
152 if (!$this->get_imap_client()) {
153 return false;
154 }
155
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);
162
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();
167
168 // Retrieve the message id.
169 $messages = $this->client->fetch(self::MAILBOX, $query, array('ids' => $results['match']));
170
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 }
176
177 // Close the client connection.
178 $this->close_connection();
179
180 return true;
181 }
182
183 /**
184 * Process a message received and validated by the Inbound Message processor.
185 *
365c4edc 186 * @param \stdClass $maildata The data retrieved from the database for the current record.
77c0a68d 187 * @return bool Whether the message was successfully processed.
365c4edc 188 * @throws \core\message\inbound\processing_failed_exception if the message cannot be found.
77c0a68d
AN
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 }
195
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 . "'");
202
203 // Match the message ID.
204 $search->headerText('message-id', $maildata->messageid);
205 $search->headerText('to', $maildata->address);
206
207 $results = $this->client->search(self::CONFIRMATIONFOLDER, $search);
208
209 // Build the base query.
210 $query = new \Horde_Imap_Client_Fetch_Query();
211 $query->envelope();
212 $query->structure();
213
214
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.");
220
221 // Process the message.
222 $this->process_message($message, true, true);
223
224 // Close the client connection.
225 $this->close_connection();
226
227 mtrace("============================================================================");
228 return true;
229 } else {
230 // Close the client connection.
231 $this->close_connection();
232
233 mtrace("============================================================================");
234 throw new \core\message\inbound\processing_failed_exception('oldmessagenotfound', 'tool_messageinbound');
235 }
236 }
237
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 }
248
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);
253
254 $mailbox = $this->get_mailbox();
255
256 // Build the search.
257 $search = new \Horde_Imap_Client_Search_Query();
258
259 // Delete messages older than 24 hours old.
260 $search->intervalSearch(DAYSECS, \Horde_Imap_Client_Search_Query::INTERVAL_OLDER);
261
262 $results = $this->client->search($mailbox, $search);
263
264 // Build the base query.
265 $query = new \Horde_Imap_Client_Fetch_Query();
266 $query->envelope();
267
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 }
274
275 mtrace("Finished removing messages.");
276 $this->close_connection();
277
278 return true;
279 }
280
281 /**
282 * Process a message and pass it through the Inbound Message handling systems.
283 *
365c4edc 284 * @param \Horde_Imap_Client_Data_Fetch $message The message to process
77c0a68d 285 * @param bool $viewreadmessages Whether to also look at messages which have been marked as read
365c4edc 286 * @param bool $skipsenderverification Whether to skip the sender verification stage
77c0a68d
AN
287 */
288 public function process_message(
289 \Horde_Imap_Client_Data_Fetch $message,
290 $viewreadmessages = false,
291 $skipsenderverification = false) {
292 global $USER;
293
294 // We use the Client IDs several times - store them here.
295 $messageid = new \Horde_Imap_Client_Ids($message->getUid());
296
297 mtrace("- Parsing message " . $messageid);
298
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);
301
dd6c9eeb
AN
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 }
306
77c0a68d
AN
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;
310
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 }
319
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);
326
327 mtrace("-- Subject:\t" . $envelope->subject);
328 mtrace("-- From:\t" . $sender);
329 mtrace("-- Recipient:\t" . $recipient);
330
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();
337
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 }
343
344 // Mark it as read to lock the message.
345 $this->add_flag_to_message($messageid, self::MESSAGE_SEEN);
346
347 // Now pass it through the Inbound Message processor.
348 $status = $this->addressmanager->process_envelope($recipient, $sender);
349
262abe63
AN
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 }
358
77c0a68d
AN
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}");
364
365 // Remove the seen flag from the message as there may be multiple recipients.
366 $this->remove_flag_from_message($messageid, self::MESSAGE_SEEN);
367
368 // Skip further processing for this recipient.
369 continue;
370 }
371
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);
376
377 // Process and retrieve the message data for this message.
378 // This includes fetching the full content, as well as all headers, and attachments.
35df3d53
AN
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 }
77c0a68d
AN
383
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.
391
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 }
398
399 // Returning to normal cron user.
400 mtrace("-- Returning to the original user.");
401 cron_setup_user($originaluser);
402 return;
403 }
404
405 // Add the content and attachment data.
406 mtrace("-- Validation completed. Fetching rest of message content.");
407 $this->process_message_data_body($messagedata, $messageid);
408
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());
417
418 // Returning to normal cron user.
419 mtrace("-- Returning to the original user.");
420 cron_setup_user($originaluser);
421 return;
6deca428 422 } catch (\Exception $e) {
77c0a68d
AN
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());
426
427 // Returning to normal cron user.
428 mtrace("-- Returning to the original user.");
429 cron_setup_user($originaluser);
430 return;
431 }
432
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 }
440
441 // Returning to normal cron user.
442 mtrace("-- Returning to the original user.");
443 cron_setup_user($originaluser);
444
445 mtrace("-- Finished processing " . $message->getUid());
446
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 }
452
453 /**
454 * Process a message to retrieve it's header data without body and attachemnts.
455 *
365c4edc
SH
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
77c0a68d
AN
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) {
465
466 // Get the current mailbox.
467 $mailbox = $this->get_mailbox();
468
469 // We need the structure at various points below.
470 $structure = $basemessagedata->getStructure();
471
472 // Now fetch the rest of the message content.
473 $query = new \Horde_Imap_Client_Fetch_Query();
474 $query->imapDate();
475
35df3d53
AN
476 // Fetch the message header.
477 $query->headerText();
77c0a68d 478
35df3d53 479 // Retrieve the message with the above components.
77c0a68d
AN
480 $messagedata = $this->client->fetch($mailbox, $query, array('ids' => $messageid))->first();
481
35df3d53
AN
482 if (!$messagedata) {
483 // Message was not found! Somehow it has been removed or is no longer returned.
484 return null;
77c0a68d
AN
485 }
486
77c0a68d 487 // The message ID should always be in the first part.
35df3d53 488 $data = new \stdClass();
77c0a68d
AN
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();
35df3d53 494 $data->headers = $messagedata->getHeaderText();
77c0a68d
AN
495
496 $this->currentmessagedata = $data;
497
498 return $this->currentmessagedata;
499 }
500
501 /**
502 * Process a message again to add body and attachment data.
503 *
6deca428
AA
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
77c0a68d
AN
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;
512
513 // Get the current mailbox.
514 $mailbox = $this->get_mailbox();
515
516 // We need the structure at various points below.
517 $structure = $basemessagedata->getStructure();
518
519 // Now fetch the rest of the message content.
520 $query = new \Horde_Imap_Client_Fetch_Query();
521 $query->fullText();
522
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 }
533
534 $messagedata = $this->client->fetch($mailbox, $query, array('ids' => $messageid))->first();
535
536 // Store the data for this message.
537 $contentplain = '';
538 $contenthtml = '';
539 $attachments = array(
540 'inline' => array(),
541 'attachment' => array(),
542 );
543
544 $plainpartid = $structure->findBody('plain');
545 $htmlpartid = $structure->findBody('html');
546
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 ));
554
555 if ($part === $plainpartid) {
556 $contentplain = $this->process_message_part_body($messagedata, $partdata, $part);
557
558 } else if ($part === $htmlpartid) {
559 $contenthtml = $this->process_message_part_body($messagedata, $partdata, $part);
560
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 }
570
571 // We don't handle any of the other MIME content at this stage.
572 }
573
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;
578
579 return $this->currentmessagedata;
580 }
581
582 /**
583 * Process the messagedata and part data to extract the content of this part.
584 *
365c4edc
SH
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
77c0a68d
AN
588 * @return string
589 */
590 private function process_message_part_body($messagedata, $partdata, $part) {
591 // This is a content section for the main body.
592
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 }
600
601 // Convert the text from the current encoding to UTF8.
602 $content = \core_text::convert($content, $partdata->getCharset());
603
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);
608
609 return $content;
610 }
611
612 /**
613 * Process a message again to add body and attachment data.
614 *
365c4edc
SH
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
77c0a68d 619 * @return \stdClass
365c4edc 620 * @throws \core\message\inbound\processing_failed_exception If the attachment can't be saved to disk.
77c0a68d
AN
621 */
622 private function process_message_part_attachment($messagedata, $partdata, $part, $filename) {
623 global $CFG;
624
625 // If a filename is present, assume that this part is an attachment.
626 $attachment = new \stdClass();
627 $attachment->filename = $filename;
628 $attachment->type = $partdata->getType();
629 $attachment->content = $partdata->getContents();
630 $attachment->charset = $partdata->getCharset();
631 $attachment->description = $partdata->getDescription();
632 $attachment->contentid = $partdata->getContentId();
633 $attachment->filesize = $messagedata->getBodyPartSize($part);
634
001feb66 635 if (!empty($CFG->antiviruses)) {
77c0a68d
AN
636 mtrace("--> Attempting virus scan of '{$attachment->filename}'");
637
638 // Store the file on disk - it will need to be virus scanned first.
639 $itemid = rand(1, 999999999);;
640 $directory = make_temp_directory("/messageinbound/{$itemid}", false);
641 $filepath = $directory . "/" . $attachment->filename;
642 if (!$fp = fopen($filepath, "w")) {
643 // Unable to open the temporary file to write this to disk.
644 mtrace("--> Unable to save the file to disk for virus scanning. Check file permissions.");
645
646 throw new \core\message\inbound\processing_failed_exception('attachmentfilepermissionsfailed',
647 'tool_messageinbound');
648 }
649
650 fwrite($fp, $attachment->content);
651 fclose($fp);
652
653 // Perform a virus scan now.
654 try {
11362ae3
RK
655 \core\antivirus\manager::scan_file($filepath, $attachment->filename, true);
656 } catch (\core\antivirus\scanner_exception $e) {
77c0a68d
AN
657 mtrace("--> A virus was found in the attachment '{$attachment->filename}'.");
658 $this->inform_attachment_virus();
659 return;
660 }
661 }
662
663 return $attachment;
664 }
665
666 /**
667 * Check whether the key provided is valid.
668 *
365c4edc
SH
669 * @param bool $status
670 * @param mixed $messageid The Hore message Uid
77c0a68d
AN
671 * @return bool
672 */
673 private function passes_key_validation($status, $messageid) {
674 // The validation result is tested in a bitwise operation.
675 if ((
676 $status & ~ \core\message\inbound\address_manager::VALIDATION_SUCCESS
677 & ~ \core\message\inbound\address_manager::VALIDATION_UNKNOWN_DATAKEY
678 & ~ \core\message\inbound\address_manager::VALIDATION_EXPIRED_DATAKEY
679 & ~ \core\message\inbound\address_manager::VALIDATION_INVALID_HASH
680 & ~ \core\message\inbound\address_manager::VALIDATION_ADDRESS_MISMATCH) !== 0) {
681
682 // One of the above bits was found in the status - fail the validation.
683 return false;
684 }
685 return true;
686 }
687
688 /**
689 * Add the specified flag to the message.
690 *
365c4edc 691 * @param mixed $messageid
77c0a68d
AN
692 * @param string $flag The flag to add
693 */
694 private function add_flag_to_message($messageid, $flag) {
695 // Get the current mailbox.
696 $mailbox = $this->get_mailbox();
697
698 // Mark it as read to lock the message.
699 $this->client->store($mailbox, array(
700 'ids' => new \Horde_Imap_Client_Ids($messageid),
701 'add' => $flag,
702 ));
703 }
704
705 /**
706 * Remove the specified flag from the message.
707 *
365c4edc 708 * @param mixed $messageid
77c0a68d
AN
709 * @param string $flag The flag to remove
710 */
711 private function remove_flag_from_message($messageid, $flag) {
712 // Get the current mailbox.
713 $mailbox = $this->get_mailbox();
714
715 // Mark it as read to lock the message.
716 $this->client->store($mailbox, array(
717 'ids' => $messageid,
718 'delete' => $flag,
719 ));
720 }
721
722 /**
723 * Check whether the message has the specified flag
724 *
365c4edc 725 * @param mixed $messageid
77c0a68d
AN
726 * @param string $flag The flag to check
727 * @return bool
728 */
729 private function message_has_flag($messageid, $flag) {
730 // Get the current mailbox.
731 $mailbox = $this->get_mailbox();
732
733 // Grab messagedata including flags.
734 $query = new \Horde_Imap_Client_Fetch_Query();
735 $query->flags();
736 $query->structure();
737 $messagedata = $this->client->fetch($mailbox, $query, array(
738 'ids' => $messageid,
739 ))->first();
740 $flags = $messagedata->getFlags();
741
742 return in_array($flag, $flags);
743 }
744
fd424b99
AN
745 /**
746 * Ensure that all mailboxes exist.
747 */
748 private function ensure_mailboxes_exist() {
749 $requiredmailboxes = array(
750 self::MAILBOX,
751 self::CONFIRMATIONFOLDER,
752 );
753
754 $existingmailboxes = $this->client->listMailboxes($requiredmailboxes);
755 foreach ($requiredmailboxes as $mailbox) {
756 if (isset($existingmailboxes[$mailbox])) {
757 // This mailbox was found.
758 continue;
759 }
760
761 mtrace("Unable to find the '{$mailbox}' mailbox - creating it.");
762 $this->client->createMailbox($mailbox);
763 }
764 }
765
dd6c9eeb
AN
766 /**
767 * Attempt to determine whether this message is a bulk message (e.g. automated reply).
768 *
769 * @param \Horde_Imap_Client_Data_Fetch $message The message to process
770 * @param string|\Horde_Imap_Client_Ids $messageid The Hore message Uid
771 * @return boolean
772 */
773 private function is_bulk_message(
774 \Horde_Imap_Client_Data_Fetch $message,
775 $messageid) {
776 $query = new \Horde_Imap_Client_Fetch_Query();
777 $query->headerText(array('peek' => true));
778
779 $messagedata = $this->client->fetch($this->get_mailbox(), $query, array('ids' => $messageid))->first();
780
781 // Assume that this message is not bulk to begin with.
782 $isbulk = false;
783
784 // An auto-reply may itself include the Bulk Precedence.
785 $precedence = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Precedence');
786 $isbulk = $isbulk || strtolower($precedence) == 'bulk';
787
788 // If the X-Autoreply header is set, and not 'no', then this is an automatic reply.
789 $autoreply = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('X-Autoreply');
790 $isbulk = $isbulk || ($autoreply && $autoreply != 'no');
791
792 // If the X-Autorespond header is set, and not 'no', then this is an automatic response.
793 $autorespond = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('X-Autorespond');
794 $isbulk = $isbulk || ($autorespond && $autorespond != 'no');
795
796 // If the Auto-Submitted header is set, and not 'no', then this is a non-human response.
797 $autosubmitted = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Auto-Submitted');
798 $isbulk = $isbulk || ($autosubmitted && $autosubmitted != 'no');
799
800 return $isbulk;
801 }
802
77c0a68d
AN
803 /**
804 * Send the message to the appropriate handler.
805 *
365c4edc
SH
806 * @return bool
807 * @throws \core\message\inbound\processing_failed_exception if anything goes wrong.
77c0a68d
AN
808 */
809 private function send_to_handler() {
810 try {
811 mtrace("--> Passing to Inbound Message handler {$this->addressmanager->get_handler()->classname}");
812 if ($result = $this->addressmanager->handle_message($this->currentmessagedata)) {
813 $this->inform_user_of_success($this->currentmessagedata, $result);
814 // Request that this message be marked for deletion.
815 return true;
816 }
817
818 } catch (\core\message\inbound\processing_failed_exception $e) {
819 mtrace("-> The Inbound Message handler threw an exception. Unable to process this message. The user has been informed.");
820 mtrace("--> " . $e->getMessage());
821 // Throw the exception again, with additional data.
822 $error = new \stdClass();
823 $error->subject = $this->currentmessagedata->envelope->subject;
824 $error->message = $e->getMessage();
825 throw new \core\message\inbound\processing_failed_exception('messageprocessingfailed', 'tool_messageinbound', $error);
826
6deca428 827 } catch (\Exception $e) {
77c0a68d
AN
828 mtrace("-> The Inbound Message handler threw an exception. Unable to process this message. User informed.");
829 mtrace("--> " . $e->getMessage());
830 // An unknown error occurred. Still inform the user but, this time do not include the specific
831 // message information.
832 $error = new \stdClass();
833 $error->subject = $this->currentmessagedata->envelope->subject;
834 throw new \core\message\inbound\processing_failed_exception('messageprocessingfailedunknown',
835 'tool_messageinbound', $error);
836
837 }
838
839 // Something went wrong and the message was not handled well in the Inbound Message handler.
840 mtrace("-> The Inbound Message handler reported an error. The message may not have been processed.");
841
842 // It is the responsiblity of the handler to throw an appropriate exception if the message was not processed.
843 // Do not inform the user at this point.
844 return false;
845 }
846
847 /**
848 * Handle failure of sender verification.
849 *
850 * This will send a notification to the user identified in the Inbound Message address informing them that a message has been
851 * stored. The message includes a verification link and reply-to address which is handled by the
852 * invalid_recipient_handler.
853 *
365c4edc
SH
854 * @param \Horde_Imap_Client_Ids $messageids
855 * @param string $recipient The message recipient
856 * @return bool
77c0a68d
AN
857 */
858 private function handle_verification_failure(
859 \Horde_Imap_Client_Ids $messageids,
860 $recipient) {
861 global $DB, $USER;
862
863 if (!$messageid = $this->currentmessagedata->messageid) {
864 mtrace("---> Warning: Unable to determine the Message-ID of the message.");
865 return false;
866 }
867
868 // Move the message into a new mailbox.
869 $this->client->copy(self::MAILBOX, self::CONFIRMATIONFOLDER, array(
870 'create' => true,
871 'ids' => $messageids,
872 'move' => true,
873 ));
874
875 // Store the data from the failed message in the associated table.
876 $record = new \stdClass();
877 $record->messageid = $messageid;
878 $record->userid = $USER->id;
879 $record->address = $recipient;
880 $record->timecreated = time();
881 $record->id = $DB->insert_record('messageinbound_messagelist', $record);
882
883 // Setup the Inbound Message generator for the invalid recipient handler.
884 $addressmanager = new \core\message\inbound\address_manager();
885 $addressmanager->set_handler('\tool_messageinbound\message\inbound\invalid_recipient_handler');
886 $addressmanager->set_data($record->id);
887
888 $eventdata = new \stdClass();
889 $eventdata->component = 'tool_messageinbound';
890 $eventdata->name = 'invalidrecipienthandler';
891
892 $userfrom = clone $USER;
893 $userfrom->customheaders = array();
894 // Adding the In-Reply-To header ensures that it is seen as a reply.
895 $userfrom->customheaders[] = 'In-Reply-To: ' . $messageid;
896
897 // The message will be sent from the intended user.
6aec2748 898 $eventdata->userfrom = \core_user::get_support_user();
77c0a68d
AN
899 $eventdata->userto = $USER;
900 $eventdata->subject = $this->get_reply_subject($this->currentmessagedata->envelope->subject);
901 $eventdata->fullmessage = get_string('invalidrecipientdescription', 'tool_messageinbound', $this->currentmessagedata);
902 $eventdata->fullmessageformat = FORMAT_PLAIN;
903 $eventdata->fullmessagehtml = get_string('invalidrecipientdescriptionhtml', 'tool_messageinbound', $this->currentmessagedata);
904 $eventdata->smallmessage = $eventdata->fullmessage;
905 $eventdata->notification = 1;
906 $eventdata->replyto = $addressmanager->generate($USER->id);
907
908 mtrace("--> Sending a message to the user to report an verification failure.");
909 if (!message_send($eventdata)) {
910 mtrace("---> Warning: Message could not be sent.");
911 return false;
912 }
913
914 return true;
915 }
916
917 /**
918 * Inform the identified sender of a processing error.
919 *
920 * @param string $error The error message
921 */
922 private function inform_user_of_error($error) {
923 global $USER;
924
925 // The message will be sent from the intended user.
926 $userfrom = clone $USER;
927 $userfrom->customheaders = array();
928
929 if ($messageid = $this->currentmessagedata->messageid) {
930 // Adding the In-Reply-To header ensures that it is seen as a reply and threading is maintained.
931 $userfrom->customheaders[] = 'In-Reply-To: ' . $messageid;
932 }
933
934 $messagedata = new \stdClass();
935 $messagedata->subject = $this->currentmessagedata->envelope->subject;
936 $messagedata->error = $error;
937
938 $eventdata = new \stdClass();
939 $eventdata->component = 'tool_messageinbound';
940 $eventdata->name = 'messageprocessingerror';
941 $eventdata->userfrom = $userfrom;
942 $eventdata->userto = $USER;
943 $eventdata->subject = self::get_reply_subject($this->currentmessagedata->envelope->subject);
944 $eventdata->fullmessage = get_string('messageprocessingerror', 'tool_messageinbound', $messagedata);
945 $eventdata->fullmessageformat = FORMAT_PLAIN;
946 $eventdata->fullmessagehtml = get_string('messageprocessingerrorhtml', 'tool_messageinbound', $messagedata);
947 $eventdata->smallmessage = $eventdata->fullmessage;
948 $eventdata->notification = 1;
949
950 if (message_send($eventdata)) {
951 mtrace("---> Notification sent to {$USER->email}.");
952 } else {
953 mtrace("---> Unable to send notification.");
954 }
955 }
956
957 /**
958 * Inform the identified sender that message processing was successful.
959 *
365c4edc 960 * @param \stdClass $messagedata The data for the current message being processed.
77c0a68d 961 * @param mixed $handlerresult The result returned by the handler.
365c4edc 962 * @return bool
77c0a68d
AN
963 */
964 private function inform_user_of_success(\stdClass $messagedata, $handlerresult) {
965 global $USER;
966
967 // Check whether the handler has a success notification.
968 $handler = $this->addressmanager->get_handler();
969 $message = $handler->get_success_message($messagedata, $handlerresult);
970
971 if (!$message) {
972 mtrace("---> Handler has not defined a success notification e-mail.");
973 return false;
974 }
975
976 // Wrap the message in the notification wrapper.
977 $messageparams = new \stdClass();
978 $messageparams->html = $message->html;
979 $messageparams->plain = $message->plain;
980 $messagepreferencesurl = new \moodle_url("/message/edit.php", array('id' => $USER->id));
981 $messageparams->messagepreferencesurl = $messagepreferencesurl->out();
982 $htmlmessage = get_string('messageprocessingsuccesshtml', 'tool_messageinbound', $messageparams);
983 $plainmessage = get_string('messageprocessingsuccess', 'tool_messageinbound', $messageparams);
984
985 // The message will be sent from the intended user.
986 $userfrom = clone $USER;
987 $userfrom->customheaders = array();
988
989 if ($messageid = $this->currentmessagedata->messageid) {
990 // Adding the In-Reply-To header ensures that it is seen as a reply and threading is maintained.
991 $userfrom->customheaders[] = 'In-Reply-To: ' . $messageid;
992 }
993
994 $messagedata = new \stdClass();
995 $messagedata->subject = $this->currentmessagedata->envelope->subject;
996
997 $eventdata = new \stdClass();
998 $eventdata->component = 'tool_messageinbound';
999 $eventdata->name = 'messageprocessingsuccess';
1000 $eventdata->userfrom = $userfrom;
1001 $eventdata->userto = $USER;
1002 $eventdata->subject = self::get_reply_subject($this->currentmessagedata->envelope->subject);
1003 $eventdata->fullmessage = $plainmessage;
1004 $eventdata->fullmessageformat = FORMAT_PLAIN;
1005 $eventdata->fullmessagehtml = $htmlmessage;
1006 $eventdata->smallmessage = $eventdata->fullmessage;
1007 $eventdata->notification = 1;
1008
1009 if (message_send($eventdata)) {
1010 mtrace("---> Success notification sent to {$USER->email}.");
1011 } else {
1012 mtrace("---> Unable to send success notification.");
1013 }
1014 return true;
1015 }
1016
1017 /**
1018 * Return a formatted subject line for replies.
1019 *
365c4edc 1020 * @param string $subject The subject string
77c0a68d
AN
1021 * @return string The formatted reply subject
1022 */
1023 private function get_reply_subject($subject) {
1024 $prefix = get_string('replysubjectprefix', 'tool_messageinbound');
1025 if (!(substr($subject, 0, strlen($prefix)) == $prefix)) {
1026 $subject = $prefix . ' ' . $subject;
1027 }
1028
1029 return $subject;
1030 }
1031}