diff --git a/actions/notification/classes/helper.php b/actions/notification/classes/helper.php new file mode 100644 index 0000000..9c7782b --- /dev/null +++ b/actions/notification/classes/helper.php @@ -0,0 +1,405 @@ +. + +/** + * Pulse notification action helper - Contains methods to send pulse notifications to users. + * + * @package pulseaction_notification + * @copyright 2023, bdecent gmbh bdecent.de + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace pulseaction_notification; + +use stdClass; +use core_user; +use cache; + +/** + * Pulse notification action helper to send messages to users. + */ +class helper { + + /** + * Send pulse notifications to the users. + * + * @param mixed $userto + * @param mixed $subject + * @param mixed $messageplain + * @param mixed $messagehtml + * @param mixed $pulse + * @param mixed $sender + * @param array $cc List of cc users. + * @param array $bcc List of bcc users. + * + * @return void + */ + public static function messagetouser($userto, $subject, $messageplain, $messagehtml, $pulse, $sender=true, $cc=[], $bcc=[]) { + global $CFG; + + require_once($CFG->dirroot.'/mod/pulse/lib.php'); + + $eventdata = new \core\message\message(); + $eventdata->name = 'mod_pulse'; + $eventdata->component = 'mod_pulse'; + $eventdata->courseid = $pulse->course; + $eventdata->modulename = 'pulse'; + $eventdata->userfrom = $sender ? $sender : core_user::get_support_user(); + $eventdata->userto = $userto; + $eventdata->subject = $subject; + $eventdata->fullmessage = $messageplain; + $eventdata->fullmessageformat = FORMAT_HTML; + $eventdata->fullmessagehtml = $messagehtml; + $eventdata->smallmessage = $subject; + $eventdata->customdata = ['cc' => $cc, 'bcc' => $bcc]; + if (self::message_send($eventdata)) { + pulse_mtrace( "Pulse send to the user."); + return true; + } else { + pulse_mtrace( "Failed - Pulse send to the user. -".fullname($userto), true); + return false; + } + } + + /** + * MODIFIED the core message_send method to send the message using pulse notification manager instead of core/mesage/manager. + * + * Called when a message provider wants to send a message. + * This functions checks the message recipient's message processor configuration then + * sends the message to the configured processors + * + * Required parameters of the $eventdata object: + * component string component name. must exist in message_providers + * name string message type name. must exist in message_providers + * userfrom object|int the user sending the message + * userto object|int the message recipient + * subject string the message subject + * fullmessage string the full message in a given format + * fullmessageformat int the format if the full message (FORMAT_MOODLE, FORMAT_HTML, ..) + * fullmessagehtml string the full version (the message processor will choose with one to use) + * smallmessage string the small version of the message + * + * Optional parameters of the $eventdata object: + * notification bool should the message be considered as a notification rather than a personal message + * contexturl string if this is a notification then you can specify a url to view the event. + * For example the forum post the user is being notified of. + * contexturlname string the display text for contexturl + * + * Note: processor failure will not reported as false return value in all scenarios, + * for example when it is called while a database transaction is open, + * earlier versions did not do it consistently either. + * + * @copyright 2008 Luis Rodrigues and Martin Dougiamas + * @category message + * @param \core\message\message $eventdata information about the message (component, userfrom, userto, ...) + * @return mixed the integer ID of the new message or false if there was a problem + */ + public static function message_send(\core\message\message $eventdata) { + global $CFG, $DB, $SITE; + + require_once($CFG->dirroot. '/lib/messagelib.php'); + + // New message ID to return. + $messageid = false; + + // Fetch default (site) preferences. + $defaultpreferences = get_message_output_default_preferences(); + $preferencebase = $eventdata->component.'_'.$eventdata->name; + + // If the message provider is disabled via preferences, then don't send the message. + if (!empty($defaultpreferences->{$preferencebase.'_disable'})) { + return $messageid; + } + + // By default a message is a notification. Only personal/private messages aren't notifications. + if (!isset($eventdata->notification)) { + $eventdata->notification = 1; + } + + if (!is_object($eventdata->userfrom)) { + $eventdata->userfrom = core_user::get_user($eventdata->userfrom); + } + if (!$eventdata->userfrom) { + debugging('Attempt to send msg from unknown user', DEBUG_NORMAL); + return false; + } + + // Legacy messages (FROM a single user TO a single user) must be converted into conversation messages. + // Then, these will be passed through the conversation messages code below. + if (!$eventdata->notification && !$eventdata->convid) { + // If messaging is disabled at the site level, then the 'instantmessage' provider is always disabled. + // Given this is the only 'message' type message provider, we can exit now if this is the case. + // Don't waste processing time trying to work out the other conversation member, + // If it's an individual conversation, just throw a generic debugging notice and return. + if (empty($CFG->messaging) || $eventdata->component !== 'moodle' || $eventdata->name !== 'instantmessage') { + debugging('Attempt to send msg from a provider '.$eventdata->component.'/'.$eventdata->name. + ' that is inactive or not allowed for the user id='.$eventdata->userto->id, DEBUG_NORMAL); + return false; + } + + if (!is_object($eventdata->userto)) { + $eventdata->userto = core_user::get_user($eventdata->userto); + } + if (!$eventdata->userto) { + debugging('Attempt to send msg to unknown user', DEBUG_NORMAL); + return false; + } + + // Verify all necessary data fields are present. + if (!isset($eventdata->userto->auth) or !isset($eventdata->userto->suspended) + or !isset($eventdata->userto->deleted) or !isset($eventdata->userto->emailstop)) { + + debugging('Necessary properties missing in userto object, fetching full record', DEBUG_DEVELOPER); + $eventdata->userto = core_user::get_user($eventdata->userto->id); + } + + $usertoisrealuser = (core_user::is_real_user($eventdata->userto->id) != false); + // If recipient is internal user (noreply user), and emailstop is set then don't send any msg. + if (!$usertoisrealuser && !empty($eventdata->userto->emailstop)) { + debugging('Attempt to send msg to internal (noreply) user', DEBUG_NORMAL); + return false; + } + + if ($eventdata->userfrom->id == $eventdata->userto->id) { + // It's a self conversation. + $conversation = \core_message\api::get_self_conversation($eventdata->userfrom->id); + if (empty($conversation)) { + $conversation = \core_message\api::create_conversation( + \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF, + [$eventdata->userfrom->id] + ); + } + } else { + if (!$conversationid = \core_message\api::get_conversation_between_users([$eventdata->userfrom->id, + $eventdata->userto->id])) { + // It's a private conversation between users. + $conversation = \core_message\api::create_conversation( + \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, + [ + $eventdata->userfrom->id, + $eventdata->userto->id + ] + ); + } + } + // We either have found a conversation, or created one. + $conversationid = !empty($conversationid) ? $conversationid : $conversation->id; + $eventdata->convid = $conversationid; + } + + // This is a message directed to a conversation, not a specific user as was the way in legacy messaging. + // The above code has adapted the legacy messages into conversation messages. + // We must call send_message_to_conversation(), which handles per-member processor iteration and triggers + // a per-conversation event. + // All eventdata for messages should now have a convid, as we fixed this above. + if (!$eventdata->notification) { + + // Only one message will be saved to the DB. + $conversationid = $eventdata->convid; + $table = 'messages'; + $tabledata = new stdClass(); + $tabledata->courseid = $eventdata->courseid; + $tabledata->useridfrom = $eventdata->userfrom->id; + $tabledata->conversationid = $conversationid; + $tabledata->subject = $eventdata->subject; + $tabledata->fullmessage = $eventdata->fullmessage; + $tabledata->fullmessageformat = $eventdata->fullmessageformat; + $tabledata->fullmessagehtml = $eventdata->fullmessagehtml; + $tabledata->smallmessage = $eventdata->smallmessage; + $tabledata->timecreated = time(); + $tabledata->customdata = $eventdata->customdata; + + // The Trusted Content system. + // Texts created or uploaded by such users will be marked as trusted and will not be cleaned before display. + if (trusttext_active()) { + // Individual conversations are always in system context. + $messagecontext = \context_system::instance(); + // We need to know the type of conversation and the contextid if it is a group conversation. + if ($conv = $DB->get_record('message_conversations', ['id' => $conversationid], 'id, type, contextid')) { + if ($conv->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP && $conv->contextid) { + $messagecontext = \context::instance_by_id($conv->contextid); + } + } + $tabledata->fullmessagetrust = trusttext_trusted($messagecontext); + } else { + $tabledata->fullmessagetrust = false; + } + + if ($messageid = message_handle_phpunit_redirection($eventdata, $table, $tabledata)) { + return $messageid; + } + + // Cache messages. + if (!empty($eventdata->convid)) { + // Cache the timecreated value of the last message in this conversation. + $cache = cache::make('core', 'message_time_last_message_between_users'); + $key = \core_message\helper::get_last_message_time_created_cache_key($eventdata->convid); + $cache->set($key, $tabledata->timecreated); + } + + // Store unread message just in case we get a fatal error any time later. + $tabledata->id = $DB->insert_record($table, $tabledata); + $eventdata->savedmessageid = $tabledata->id; + + return \core\message\manager::send_message_to_conversation($eventdata, $tabledata); + } + + // Else the message is a notification. + if (!is_object($eventdata->userto)) { + $eventdata->userto = core_user::get_user($eventdata->userto); + } + if (!$eventdata->userto) { + debugging('Attempt to send msg to unknown user', DEBUG_NORMAL); + return false; + } + + // If the provider's component is disabled or the user can't receive messages from it, don't send the message. + $isproviderallowed = false; + foreach (message_get_providers_for_user($eventdata->userto->id) as $provider) { + if ($provider->component === $eventdata->component && $provider->name === $eventdata->name) { + $isproviderallowed = true; + break; + } + } + if (!$isproviderallowed) { + debugging('Attempt to send msg from a provider '.$eventdata->component.'/'.$eventdata->name. + ' that is inactive or not allowed for the user id='.$eventdata->userto->id, DEBUG_NORMAL); + return false; + } + + // Verify all necessary data fields are present. + if (!isset($eventdata->userto->auth) or !isset($eventdata->userto->suspended) + or !isset($eventdata->userto->deleted) or !isset($eventdata->userto->emailstop)) { + + debugging('Necessary properties missing in userto object, fetching full record', DEBUG_DEVELOPER); + $eventdata->userto = core_user::get_user($eventdata->userto->id); + } + + $usertoisrealuser = (core_user::is_real_user($eventdata->userto->id) != false); + // If recipient is internal user (noreply user), and emailstop is set then don't send any msg. + if (!$usertoisrealuser && !empty($eventdata->userto->emailstop)) { + debugging('Attempt to send msg to internal (noreply) user', DEBUG_NORMAL); + return false; + } + + // Check if we are creating a notification or message. + $table = 'notifications'; + + $tabledata = new stdClass(); + $tabledata->useridfrom = $eventdata->userfrom->id; + $tabledata->useridto = $eventdata->userto->id; + $tabledata->subject = $eventdata->subject; + $tabledata->fullmessage = $eventdata->fullmessage; + $tabledata->fullmessageformat = $eventdata->fullmessageformat; + $tabledata->fullmessagehtml = $eventdata->fullmessagehtml; + $tabledata->smallmessage = $eventdata->smallmessage; + $tabledata->eventtype = $eventdata->name; + $tabledata->component = $eventdata->component; + $tabledata->timecreated = time(); + $tabledata->customdata = $eventdata->customdata; + if (!empty($eventdata->contexturl)) { + $tabledata->contexturl = (string)$eventdata->contexturl; + } else { + $tabledata->contexturl = null; + } + + if (!empty($eventdata->contexturlname)) { + $tabledata->contexturlname = (string)$eventdata->contexturlname; + } else { + $tabledata->contexturlname = null; + } + + if ($messageid = message_handle_phpunit_redirection($eventdata, $table, $tabledata)) { + return $messageid; + } + + // Fetch enabled processors. + $processors = get_message_processors(true); + + // Preset variables. + $processorlist = array(); + // Fill in the array of processors to be used based on default and user preferences. + foreach ($processors as $processor) { + // Skip adding processors for internal user, if processor doesn't support sending message to internal user. + if (!$usertoisrealuser && !$processor->object->can_send_to_any_users()) { + continue; + } + + // First find out permissions. + $defaultlockedpreference = $processor->name . '_provider_' . $preferencebase . '_locked'; + $locked = false; + if (isset($defaultpreferences->{$defaultlockedpreference})) { + $locked = $defaultpreferences->{$defaultlockedpreference}; + } else { + // MDL-25114 They supplied an $eventdata->component $eventdata->name combination which doesn't. + // exist in the message_provider table (thus there is no default settings for them). + $preferrormsg = "Could not load preference $defaultlockedpreference. Make sure the component and name you supplied + to message_send() are valid."; + throw new \coding_exception($preferrormsg); + } + + $preferencename = 'message_provider_'.$preferencebase.'_enabled'; + $forced = false; + if ($locked && isset($defaultpreferences->{$preferencename})) { + $userpreference = $defaultpreferences->{$preferencename}; + $forced = in_array($processor->name, explode(',', $userpreference)); + } + + // Find out if user has configured this output. + // Some processors cannot function without settings from the user. + $userisconfigured = $processor->object->is_user_configured($eventdata->userto); + + // DEBUG: notify if we are forcing unconfigured output. + if ($forced && !$userisconfigured) { + debugging( + 'Attempt to force message delivery to user who has "'.$processor->name.'" output unconfigured', DEBUG_NORMAL); + } + + // Populate the list of processors we will be using. + if ($forced && $userisconfigured) { + // An admin is forcing users to use this message processor. Use this processor unconditionally. + $processorlist[] = $processor->name; + } else if (!$forced && !$locked && $userisconfigured && !$eventdata->userto->emailstop) { + // User has not disabled notifications. + // See if user set any notification preferences, otherwise use site default ones. + if ($userpreference = get_user_preferences($preferencename, null, $eventdata->userto)) { + if (in_array($processor->name, explode(',', $userpreference))) { + $processorlist[] = $processor->name; + } + } else if (isset($defaultpreferences->{$preferencename})) { + if (in_array($processor->name, explode(',', $defaultpreferences->{$preferencename}))) { + $processorlist[] = $processor->name; + } + } + } + } + + // Store unread message just in case we get a fatal error any time later. + $tabledata->id = $DB->insert_record($table, $tabledata); + $eventdata->savedmessageid = $tabledata->id; + + // Let the manager do the sending or buffering when db transaction in progress. + try { + // PULSE MODIFY - Send the message using pulse notification manager instead of core/mesage/manager. + return \pulseaction_notification\manager::send_message($eventdata, $tabledata, $processorlist); + } catch (\moodle_exception $exception) { + return false; + } + } + + + +} diff --git a/actions/notification/classes/manager.php b/actions/notification/classes/manager.php new file mode 100644 index 0000000..dacba48 --- /dev/null +++ b/actions/notification/classes/manager.php @@ -0,0 +1,205 @@ +. + +/** + * Notification pulse action - Message manager. + * + * @package pulseaction_notification + * @copyright 2023, bdecent gmbh bdecent.de + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace pulseaction_notification; + +use core\message\message; + +/** + * Helps to send messages from available message processesors, extends the moodle core\message\manager method. + */ +class manager extends \core\message\manager { + + /** + * Do the message sending - The method to use the custom call_processor method. + * + * NOTE: to be used from message_send() only. + * + * @copyright 2014 Totara Learning Solutions Ltd {@link http://www.totaralms.com/} + * @author Petr Skoda + * + * @param \core\message\message $eventdata fully prepared event data for processors + * @param \stdClass $savemessage the message saved in 'message' table + * @param array $processorlist list of processors for target user + * @return int $messageid the id from 'messages' (false is not returned) + */ + public static function send_message(message $eventdata, \stdClass $savemessage, array $processorlist) { + global $CFG; + + require_once($CFG->dirroot.'/message/lib.php'); // This is most probably already included from messagelib.php file. + + if (empty($processorlist)) { + // Trigger event for sending a message or notification - we need to do this before marking as read! + self::trigger_message_events($eventdata, $savemessage); + + if ($eventdata->notification) { + // If they have deselected all processors and it's a notification mark it read. The user doesn't want to be + // bothered. + $savemessage->timeread = null; + \core_message\api::mark_notification_as_read($savemessage); + } else if (empty($CFG->messaging)) { + // If it's a message and messaging is disabled mark it read. + \core_message\api::mark_message_as_read($eventdata->userto->id, $savemessage); + } + + return $savemessage->id; + } + + // Let the manager do the sending or buffering when db transaction in progress. + return self::send_message_to_processors($eventdata, $savemessage, $processorlist); + } + + /** + * Send message to message processors - Inherit the method to use the custom call_processor method. + * + * @copyright 2014 Totara Learning Solutions Ltd {@link http://www.totaralms.com/} + * @author Petr Skoda + * + * @param \stdClass|\core\message\message $eventdata + * @param \stdClass $savemessage + * @param array $processorlist + * @throws \moodle_exception + * @return int $messageid + */ + protected static function send_message_to_processors($eventdata, \stdClass $savemessage, array + $processorlist) { + global $CFG, $DB; + + // We cannot communicate with external systems in DB transactions, + // buffer the messages if necessary. + if ($DB->is_transaction_started()) { + // We need to clone all objects so that devs may not modify it from outside later. + $eventdata = clone($eventdata); + $eventdata->userto = clone($eventdata->userto); + $eventdata->userfrom = clone($eventdata->userfrom); + + // Conserve some memory the same was as $USER setup does. + unset($eventdata->userto->description); + unset($eventdata->userfrom->description); + + self::$buffer[] = array($eventdata, $savemessage, $processorlist); + return $savemessage->id; + } + + // Send the message to processors. + if (!self::call_processors($eventdata, $processorlist)) { + throw new \moodle_exception("Message was not sent."); + } + + // Trigger event for sending a message or notification - we need to do this before marking as read! + self::trigger_message_events($eventdata, $savemessage); + + if (!$eventdata->notification && empty($CFG->messaging)) { + // If it's a message and messaging is disabled mark it read. + \core_message\api::mark_message_as_read($eventdata->userto->id, $savemessage); + } + + return $savemessage->id; + } + + /** + * For each processor, call it's send_message() method + * - This method is modified version of core\message\manager call_processors. + * - Modifed to use our custom email message procesessor instead of default message_output_email method. + * - By updating the email processor pulse includes the CC and Bcc emails. + * + * @copyright 2014 Totara Learning Solutions Ltd {@link http://www.totaralms.com/} + * @author Petr Skoda + * + * @param message $eventdata the message object. + * @param array $processorlist the list of processors for a single user. + * @return bool false if error calling message processor + */ + protected static function call_processors(message $eventdata, array $processorlist) { + // Allow plugins to change the message/notification data before sending it. + $pluginsfunction = get_plugins_with_function('pre_processor_message_send'); + $sendmsgsuccessful = true; + foreach ($processorlist as $procname) { + // Let new messaging class add custom content based on the processor. + $proceventdata = ($eventdata instanceof message) ? $eventdata->get_eventobject_for_processor($procname) : $eventdata; + + if ($pluginsfunction) { + foreach ($pluginsfunction as $plugintype => $plugins) { + foreach ($plugins as $pluginfunction) { + $pluginfunction($procname, $proceventdata); + } + } + } + + $stdproc = new \stdClass(); + $stdproc->name = $procname; + + // Call the pulse email process instead of message_email_output. + $processor = ($procname == 'email') + ? self::get_processed_processor_object($stdproc) : \core_message\api::get_processed_processor_object($stdproc); + if (!$processor->object->send_message($proceventdata)) { + debugging('Error calling message processor ' . $procname); + $sendmsgsuccessful = false; + } + } + return $sendmsgsuccessful; + } + + /** + * Modified version of \core_message\api::get_processed_processor_object. + * - Fetch the pulseaction_notification_email processor. This helps to use pulse custom email processor. + * + * Given a processor object, loads information about it's settings and configurations. + * This is not a public api, instead use {@see \core_message\api::get_message_processor()} + * or {@see \get_message_processors()} + * + * @copyright 2016 Mark Nelson + * + * @param \stdClass $processor processor object + * @return \stdClass processed processor object + * @since Moodle 3.2 + */ + public static function get_processed_processor_object(\stdClass $processor) { + global $CFG; + + $processorfile = $CFG->dirroot. '/mod/pulse/actions/notification/pulseaction_notification_email.php'; + if (is_readable($processorfile)) { + include_once($processorfile); + $processclass = 'pulseaction_notification_email'; + if (class_exists($processclass)) { + $pclass = new $processclass(); + $processor->object = $pclass; + $processor->configured = 0; + if ($pclass->is_system_configured()) { + $processor->configured = 1; + } + $processor->hassettings = 0; + if (is_readable($CFG->dirroot.'/message/output/'.$processor->name.'/settings.php')) { + $processor->hassettings = 1; + } + $processor->available = 1; + } else { + throw new \moodle_exception('errorcallingprocessor', 'message'); + } + } else { + $processor->available = 0; + } + return $processor; + } +} diff --git a/actions/notification/classes/notification.php b/actions/notification/classes/notification.php index 11b0599..98eb269 100644 --- a/actions/notification/classes/notification.php +++ b/actions/notification/classes/notification.php @@ -922,8 +922,8 @@ public function generate_notification_details($moddata, $user, $context, $notifi $result = [ 'recepient' => (object) $user, - 'cc' => implode(',', array_column($ccusers, 'email')), - 'bcc' => implode(',', array_column($bccusers, 'email')), + 'cc' => array_map(fn($user) => [$user->email, fullname($user)], $ccusers), + 'bcc' => array_map(fn($user) => [$user->email, fullname($user)], $bccusers), 'subject' => format_string($this->notificationdata->subject), 'content' => $this->build_notification_content($moddata, $context, $notificationoverrides), ]; diff --git a/actions/notification/classes/schedule.php b/actions/notification/classes/schedule.php index 60a2da6..b4ebbc7 100644 --- a/actions/notification/classes/schedule.php +++ b/actions/notification/classes/schedule.php @@ -168,10 +168,8 @@ public function send_scheduled_notification($userid=null) { $sender = $this->find_sender_user(); // Add bcc and CC to sender user custom headers. - $sender->customheaders = [ - "Bcc: $detail->bcc\r\n", - "Cc: $detail->cc\r\n", - ]; + $cc = $detail->cc ?? []; + $bcc = $detail->bcc ?? []; // Prepare the module data. based on dynamic content and includ the session data. $mod = $this->prepare_moduledata_placeholders($modules, $cmdata); @@ -195,8 +193,8 @@ public function send_scheduled_notification($userid=null) { $pulse = (object) ['course' => $this->course->id]; // TODO: NOTE using notification API takes 16 queries. Direct email_to_user method will take totally 9 queries. // Send the notification to user. - $messagesend = \mod_pulse\helper::messagetouser( - $detail->recepient, $subject, $messageplain, $messagehtml, $pulse, $sender + $messagesend = \pulseaction_notification\helper::messagetouser( + $detail->recepient, $subject, $messageplain, $messagehtml, $pulse, $sender, $cc, $bcc ); if ($messagesend) { diff --git a/actions/notification/pulseaction_notification_email.php b/actions/notification/pulseaction_notification_email.php new file mode 100644 index 0000000..aed6de8 --- /dev/null +++ b/actions/notification/pulseaction_notification_email.php @@ -0,0 +1,547 @@ +. + +/** + * Contains the definiton of the email message processors (sends messages to users via email) + * + * @package pulseaction_notification + * @copyright 2023, bdecent gmbh bdecent.de + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot.'/message/output/email/message_output_email.php'); + +/** + * The email message processor for pulse notification actions. Extends the core message_output_email processor. + */ +class pulseaction_notification_email extends message_output_email { + + /** + * Processes the message (sends by email). + * - Modified the core message_output_email plugin method to send CC and BCC mails. + * + * @copyright 2008 Luis Rodrigues and Martin Dougiamas + * @param object $eventdata the event data submitted by the message sender plus $eventdata->savedmessageid + */ + public function send_message($eventdata) { + global $CFG, $DB; + + // Skip any messaging suspended and deleted users. + if ($eventdata->userto->auth === 'nologin' or $eventdata->userto->suspended or $eventdata->userto->deleted) { + return true; + } + + // The user the email is going to. + $recipient = null; + + // Check if the recipient has a different email address specified in their messaging preferences Vs their user profile. + $emailmessagingpreference = get_user_preferences('message_processor_email_email', null, $eventdata->userto); + $emailmessagingpreference = clean_param($emailmessagingpreference, PARAM_EMAIL); + + // If the recipient has set an email address in their preferences use that instead of the one in their profile, + // But only if overriding the notification email address is allowed. + if (!empty($emailmessagingpreference) && !empty($CFG->messagingallowemailoverride)) { + // Clone to avoid altering the actual user object. + $recipient = clone($eventdata->userto); + $recipient->email = $emailmessagingpreference; + } else { + $recipient = $eventdata->userto; + } + + // Check if we have attachments to send. + $attachment = ''; + $attachname = ''; + if (!empty($CFG->allowattachments) && !empty($eventdata->attachment)) { + if (empty($eventdata->attachname)) { + // Attachment needs a file name. + debugging('Attachments should have a file name. No attachments have been sent.', DEBUG_DEVELOPER); + } else if (!($eventdata->attachment instanceof stored_file)) { + // Attachment should be of a type stored_file. + debugging('Attachments should be of type stored_file. No attachments have been sent.', DEBUG_DEVELOPER); + } else { + // Copy attachment file to a temporary directory and get the file path. + $attachment = $eventdata->attachment->copy_content_to_temp(); + + // Get attachment file name. + $attachname = clean_filename($eventdata->attachname); + } + } + + // Configure mail replies - this is used for incoming mail replies. + $replyto = ''; + $replytoname = ''; + if (isset($eventdata->replyto)) { + $replyto = $eventdata->replyto; + if (isset($eventdata->replytoname)) { + $replytoname = $eventdata->replytoname; + } + } + + // Pulse - section to add bcc and cc users. + $ccaddr = []; + $bccaddr = []; + $customdata = json_decode($eventdata->customdata); + if (isset($eventdata->customdata) && isset($customdata->cc)) { + $ccaddr = $customdata->cc; + $bccaddr = $customdata->bcc; + } + + // We email messages from private conversations straight away, but for group we add them to a table to be sent later. + $emailuser = true; + if (!$eventdata->notification) { + if ($eventdata->conversationtype == \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP) { + $emailuser = false; + } + } + + if ($emailuser) { + $result = $this->email_to_user($recipient, $eventdata->userfrom, $eventdata->subject, $eventdata->fullmessage, + $eventdata->fullmessagehtml, $attachment, $attachname, true, $replyto, $replytoname, 79, $ccaddr, $bccaddr); + } else { + $messagetosend = new stdClass(); + $messagetosend->useridfrom = $eventdata->userfrom->id; + $messagetosend->useridto = $recipient->id; + $messagetosend->conversationid = $eventdata->convid; + $messagetosend->messageid = $eventdata->savedmessageid; + $result = $DB->insert_record('message_email_messages', $messagetosend, false); + } + + // Remove an attachment file if any. + if (!empty($attachment) && file_exists($attachment)) { + unlink($attachment); + } + + return $result; + } + + /** + * Send an email to a specified user - Modified the core moodlelib function to send CC and BCC mails. + * + * @copyright 1999 onwards Martin Dougiamas http://dougiamas.com + * + * @param stdClass $user A {@see $USER} object + * @param stdClass $from A {@see $USER} object + * @param string $subject plain text subject line of the email + * @param string $messagetext plain text version of the message + * @param string $messagehtml complete html version of the message (optional) + * @param string $attachment a file on the filesystem, either relative to $CFG->dataroot or a full path to a file in one of + * the following directories: $CFG->cachedir, $CFG->dataroot, $CFG->dirroot, $CFG->localcachedir, $CFG->tempdir + * @param string $attachname the name of the file (extension indicates MIME) + * @param bool $usetrueaddress determines whether $from email address should + * be sent out. Will be overruled by user profile setting for maildisplay + * @param string $replyto Email address to reply to + * @param string $replytoname Name of reply to recipient + * @param int $wordwrapwidth custom word wrap width, default 79 + * @param array $cc Cc mails list. + * @param array $bcc Bcc mails list. + * + * @return bool Returns true if mail was sent OK and false if there was an error. + */ + public function email_to_user($user, $from, $subject, $messagetext, $messagehtml = '', $attachment = '', $attachname = '', + $usetrueaddress = true, $replyto = '', $replytoname = '', $wordwrapwidth = 79, $cc=[], $bcc=[]) { + + global $CFG, $PAGE, $SITE; + + if (empty($user) or empty($user->id)) { + debugging('Can not send email to null user', DEBUG_DEVELOPER); + return false; + } + + if (empty($user->email)) { + debugging('Can not send email to user without email: '.$user->id, DEBUG_DEVELOPER); + return false; + } + + if (!empty($user->deleted)) { + debugging('Can not send email to deleted user: '.$user->id, DEBUG_DEVELOPER); + return false; + } + + if (defined('BEHAT_SITE_RUNNING')) { + // Fake email sending in behat. + return true; + } + + if (!empty($CFG->noemailever)) { + // Hidden setting for development sites, set in config.php if needed. + debugging('Not sending email due to $CFG->noemailever config setting', DEBUG_NORMAL); + return true; + } + + if (email_should_be_diverted($user->email)) { + $subject = "[DIVERTED {$user->email}] $subject"; + $user = clone($user); + $user->email = $CFG->divertallemailsto; + } + + // Skip mail to suspended users. + if ((isset($user->auth) && $user->auth == 'nologin') or (isset($user->suspended) && $user->suspended)) { + return true; + } + + if (!validate_email($user->email)) { + // We can not send emails to invalid addresses - it might create security issue or confuse the mailer. + debugging("email_to_user: User $user->id (".fullname($user).") email ($user->email) is invalid! Not sending."); + return false; + } + + if (over_bounce_threshold($user)) { + debugging("email_to_user: User $user->id (".fullname($user).") is over bounce threshold! Not sending."); + return false; + } + + // TLD .invalid is specifically reserved for invalid domain names. + // For More information, see {@link http://tools.ietf.org/html/rfc2606#section-2}. + if (substr($user->email, -8) == '.invalid') { + debugging("email_to_user: User $user->id (".fullname($user).") email domain ($user->email) is invalid! Not sending."); + return true; // This is not an error. + } + + // If the user is a remote mnet user, parse the email text for URL to the + // wwwroot and modify the url to direct the user's browser to login at their + // home site (identity provider - idp) before hitting the link itself. + if (is_mnet_remote_user($user)) { + require_once($CFG->dirroot.'/mnet/lib.php'); + + $jumpurl = mnet_get_idp_jump_url($user); + $callback = partial('mnet_sso_apply_indirection', $jumpurl); + + $messagetext = preg_replace_callback("%($CFG->wwwroot[^[:space:]]*)%", + $callback, + $messagetext); + $messagehtml = preg_replace_callback("%href=[\"'`]($CFG->wwwroot[\w_:\?=#&@/;.~-]*)[\"'`]%", + $callback, + $messagehtml); + } + $mail = get_mailer(); + + if (!empty($mail->SMTPDebug)) { + echo '
' . "\n";
+        }
+
+        $temprecipients = array();
+        $tempreplyto = array();
+
+        // Make sure that we fall back onto some reasonable no-reply address.
+        $noreplyaddressdefault = 'noreply@' . get_host_from_url($CFG->wwwroot);
+        $noreplyaddress = empty($CFG->noreplyaddress) ? $noreplyaddressdefault : $CFG->noreplyaddress;
+
+        if (!validate_email($noreplyaddress)) {
+            debugging('email_to_user: Invalid noreply-email '.s($noreplyaddress));
+            $noreplyaddress = $noreplyaddressdefault;
+        }
+
+        // Make up an email address for handling bounces.
+        if (!empty($CFG->handlebounces)) {
+            $modargs = 'B'.base64_encode(pack('V', $user->id)).substr(md5($user->email), 0, 16);
+            $mail->Sender = generate_email_processing_address(0, $modargs);
+        } else {
+            $mail->Sender = $noreplyaddress;
+        }
+
+        // Make sure that the explicit replyto is valid, fall back to the implicit one.
+        if (!empty($replyto) && !validate_email($replyto)) {
+            debugging('email_to_user: Invalid replyto-email '.s($replyto));
+            $replyto = $noreplyaddress;
+        }
+
+        if (is_string($from)) { // So we can pass whatever we want if there is need.
+            $mail->From     = $noreplyaddress;
+            $mail->FromName = $from;
+            // Check if using the true address is true, and the email is in the list of allowed domains for sending email,
+            // and that the senders email setting is either displayed to everyone, or display to only other users that are enrolled
+            // in a course with the sender.
+        } else if ($usetrueaddress && can_send_from_real_email_address($from, $user)) {
+            if (!validate_email($from->email)) {
+                debugging('email_to_user: Invalid from-email '.s($from->email).' - not sending');
+                // Better not to use $noreplyaddress in this case.
+                return false;
+            }
+            $mail->From = $from->email;
+            $fromdetails = new stdClass();
+            $fromdetails->name = fullname($from);
+            $fromdetails->url = preg_replace('#^https?://#', '', $CFG->wwwroot);
+            $fromdetails->siteshortname = format_string($SITE->shortname);
+            $fromstring = $fromdetails->name;
+            if ($CFG->emailfromvia == EMAIL_VIA_ALWAYS) {
+                $fromstring = get_string('emailvia', 'core', $fromdetails);
+            }
+            $mail->FromName = $fromstring;
+            if (empty($replyto)) {
+                $tempreplyto[] = array($from->email, fullname($from));
+            }
+        } else {
+            $mail->From = $noreplyaddress;
+            $fromdetails = new stdClass();
+            $fromdetails->name = fullname($from);
+            $fromdetails->url = preg_replace('#^https?://#', '', $CFG->wwwroot);
+            $fromdetails->siteshortname = format_string($SITE->shortname);
+            $fromstring = $fromdetails->name;
+            if ($CFG->emailfromvia != EMAIL_VIA_NEVER) {
+                $fromstring = get_string('emailvia', 'core', $fromdetails);
+            }
+            $mail->FromName = $fromstring;
+            if (empty($replyto)) {
+                $tempreplyto[] = array($noreplyaddress, get_string('noreplyname'));
+            }
+        }
+
+        if (!empty($replyto)) {
+            $tempreplyto[] = array($replyto, $replytoname);
+        }
+
+        $temprecipients[] = array($user->email, fullname($user));
+
+        // Set word wrap.
+        $mail->WordWrap = $wordwrapwidth;
+
+        if (!empty($from->customheaders)) {
+            // Add custom headers.
+            if (is_array($from->customheaders)) {
+                foreach ($from->customheaders as $customheader) {
+                    $mail->addCustomHeader($customheader);
+                }
+            } else {
+                $mail->addCustomHeader($from->customheaders);
+            }
+        }
+
+        // If the X-PHP-Originating-Script email header is on then also add an additional
+        // header with details of where exactly in moodle the email was triggered from,
+        // either a call to message_send() or to email_to_user().
+        if (ini_get('mail.add_x_header')) {
+
+            $stack = debug_backtrace(false);
+            $origin = $stack[0];
+
+            foreach ($stack as $depth => $call) {
+                if ($call['function'] == 'message_send') {
+                    $origin = $call;
+                }
+            }
+
+            $originheader = $CFG->wwwroot . ' => ' . gethostname() . ':'
+                . str_replace($CFG->dirroot . '/', '', $origin['file']) . ':' . $origin['line'];
+            $mail->addCustomHeader('X-Moodle-Originating-Script: ' . $originheader);
+        }
+
+        if (!empty($CFG->emailheaders)) {
+            $headers = array_map('trim', explode("\n", $CFG->emailheaders));
+            foreach ($headers as $header) {
+                if (!empty($header)) {
+                    $mail->addCustomHeader($header);
+                }
+            }
+        }
+
+        if (!empty($from->priority)) {
+            $mail->Priority = $from->priority;
+        }
+
+        $renderer = $PAGE->get_renderer('core');
+        $context = array(
+            'sitefullname' => $SITE->fullname,
+            'siteshortname' => $SITE->shortname,
+            'sitewwwroot' => $CFG->wwwroot,
+            'subject' => $subject,
+            'prefix' => $CFG->emailsubjectprefix,
+            'to' => $user->email,
+            'toname' => fullname($user),
+            'from' => $mail->From,
+            'fromname' => $mail->FromName,
+        );
+        if (!empty($tempreplyto[0])) {
+            $context['replyto'] = $tempreplyto[0][0];
+            $context['replytoname'] = $tempreplyto[0][1];
+        }
+        if ($user->id > 0) {
+            $context['touserid'] = $user->id;
+            $context['tousername'] = $user->username;
+        }
+
+        if (!empty($user->mailformat) && $user->mailformat == 1) {
+            // Only process html templates if the user preferences allow html email.
+
+            if (!$messagehtml) {
+                // If no html has been given, BUT there is an html wrapping template then
+                // auto convert the text to html and then wrap it.
+                $messagehtml = trim(text_to_html($messagetext));
+            }
+            $context['body'] = $messagehtml;
+            $messagehtml = $renderer->render_from_template('core/email_html', $context);
+        }
+
+        $context['body'] = html_to_text(nl2br($messagetext));
+        $mail->Subject = $renderer->render_from_template('core/email_subject', $context);
+        $mail->FromName = $renderer->render_from_template('core/email_fromname', $context);
+        $messagetext = $renderer->render_from_template('core/email_text', $context);
+
+        // Autogenerate a MessageID if it's missing.
+        if (empty($mail->MessageID)) {
+            $mail->MessageID = generate_email_messageid();
+        }
+
+        if ($messagehtml && !empty($user->mailformat) && $user->mailformat == 1) {
+            // Don't ever send HTML to users who don't want it.
+            $mail->isHTML(true);
+            $mail->Encoding = 'quoted-printable';
+            $mail->Body    = $messagehtml;
+            $mail->AltBody = "\n$messagetext\n";
+        } else {
+            $mail->IsHTML(false);
+            $mail->Body = "\n$messagetext\n";
+        }
+
+        if ($attachment && $attachname) {
+            if (preg_match( "~\\.\\.~" , $attachment )) {
+                // Security check for ".." in dir path.
+                $supportuser = core_user::get_support_user();
+                $temprecipients[] = array($supportuser->email, fullname($supportuser, true));
+                $mail->addStringAttachment(
+                    'Error in attachment.  User attempted to attach a filename with a unsafe name.',
+                    'error.txt', '8bit', 'text/plain');
+            } else {
+                require_once($CFG->libdir.'/filelib.php');
+                $mimetype = mimeinfo('type', $attachname);
+
+                // Before doing the comparison, make sure that the paths are correct (Windows uses slashes in the other direction).
+                // The absolute (real) path is also fetched to ensure that comparisons to allowed paths are compared equally.
+                $attachpath = str_replace('\\', '/', realpath($attachment));
+
+                // Build an array of all filepaths from which attachments can be added (normalised slashes, absolute/real path).
+                $allowedpaths = array_map(function(string $path): string {
+                    return str_replace('\\', '/', realpath($path));
+                }, [
+                    $CFG->cachedir,
+                    $CFG->dataroot,
+                    $CFG->dirroot,
+                    $CFG->localcachedir,
+                    $CFG->tempdir,
+                    $CFG->localrequestdir,
+                ]);
+
+                // Set addpath to true.
+                $addpath = true;
+
+                // Check if attachment includes one of the allowed paths.
+                foreach (array_filter($allowedpaths) as $allowedpath) {
+                    // Set addpath to false if the attachment includes one of the allowed paths.
+                    if (strpos($attachpath, $allowedpath) === 0) {
+                        $addpath = false;
+                        break;
+                    }
+                }
+
+                // If the attachment is a full path to a file in the multiple allowed paths, use it as is,
+                // otherwise assume it is a relative path from the dataroot (for backwards compatibility reasons).
+                if ($addpath == true) {
+                    $attachment = $CFG->dataroot . '/' . $attachment;
+                }
+
+                $mail->addAttachment($attachment, $attachname, 'base64', $mimetype);
+            }
+        }
+
+        // Check if the email should be sent in an other charset then the default UTF-8.
+        if ((!empty($CFG->sitemailcharset) || !empty($CFG->allowusermailcharset))) {
+
+            // Use the defined site mail charset or eventually the one preferred by the recipient.
+            $charset = $CFG->sitemailcharset;
+            if (!empty($CFG->allowusermailcharset)) {
+                if ($useremailcharset = get_user_preferences('mailcharset', '0', $user->id)) {
+                    $charset = $useremailcharset;
+                }
+            }
+
+            // Convert all the necessary strings if the charset is supported.
+            $charsets = get_list_of_charsets();
+            unset($charsets['UTF-8']);
+            if (in_array($charset, $charsets)) {
+                $mail->CharSet  = $charset;
+                $mail->FromName = core_text::convert($mail->FromName, 'utf-8', strtolower($charset));
+                $mail->Subject  = core_text::convert($mail->Subject, 'utf-8', strtolower($charset));
+                $mail->Body     = core_text::convert($mail->Body, 'utf-8', strtolower($charset));
+                $mail->AltBody  = core_text::convert($mail->AltBody, 'utf-8', strtolower($charset));
+
+                foreach ($temprecipients as $key => $values) {
+                    $temprecipients[$key][1] = core_text::convert($values[1], 'utf-8', strtolower($charset));
+                }
+                foreach ($tempreplyto as $key => $values) {
+                    $tempreplyto[$key][1] = core_text::convert($values[1], 'utf-8', strtolower($charset));
+                }
+            }
+        }
+
+        foreach ($temprecipients as $values) {
+            $mail->addAddress($values[0], $values[1]);
+        }
+        foreach ($tempreplyto as $values) {
+            $mail->addReplyTo($values[0], $values[1]);
+        }
+
+        // Custom method to add cc and bcc.
+        foreach ($cc as $values) {
+            $mail->addCC($values[0], $values[1]);
+        }
+        foreach ($bcc as $values) {
+            $mail->addBCC($values[0], $values[1]);
+        }
+
+        if (!empty($CFG->emaildkimselector)) {
+            $domain = substr(strrchr($mail->From, "@"), 1);
+            $pempath = "{$CFG->dataroot}/dkim/{$domain}/{$CFG->emaildkimselector}.private";
+            if (file_exists($pempath)) {
+                $mail->DKIM_domain      = $domain;
+                $mail->DKIM_private     = $pempath;
+                $mail->DKIM_selector    = $CFG->emaildkimselector;
+                $mail->DKIM_identity    = $mail->From;
+            } else {
+                debugging("Email DKIM selector chosen due to {$mail->From} but no certificate found at $pempath", DEBUG_DEVELOPER);
+            }
+        }
+
+        if ($mail->send()) {
+            set_send_count($user);
+            if (!empty($mail->SMTPDebug)) {
+                echo '
'; + } + return true; + } else { + // Trigger event for failing to send email. + $event = \core\event\email_failed::create(array( + 'context' => context_system::instance(), + 'userid' => $from->id, + 'relateduserid' => $user->id, + 'other' => array( + 'subject' => $subject, + 'message' => $messagetext, + 'errorinfo' => $mail->ErrorInfo + ) + )); + $event->trigger(); + if (CLI_SCRIPT) { + mtrace('Error: lib/moodlelib.php email_to_user(): '.$mail->ErrorInfo); + } + if (!empty($mail->SMTPDebug)) { + echo ''; + } + return false; + } + } + +} diff --git a/actions/notification/version.php b/actions/notification/version.php index 2447d84..39bf176 100644 --- a/actions/notification/version.php +++ b/actions/notification/version.php @@ -25,4 +25,4 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = "pulseaction_notification"; -$plugin->version = 2023080409; +$plugin->version = 2023080410;