Commit | Line | Data |
---|---|---|
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 | ||
25 | namespace tool_messageinbound; | |
26 | ||
27 | defined('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 | */ | |
35 | class 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 | } |