|
| 1 | +From f996d8c971e25b352725c00094da63c866facb61 Mon Sep 17 00:00:00 2001 |
| 2 | +From: Matthew Hilton <matthewhilton@catalyst-au.net> |
| 3 | +Date: Thu, 6 Nov 2025 14:57:48 +1000 |
| 4 | +Subject: [PATCH] MDL-69724 email: Add before_email_to_user hook |
| 5 | + |
| 6 | +--- |
| 7 | + .upgradenotes/MDL-69724-2025111105250585.yml | 9 ++ |
| 8 | + lib/classes/email.php | 149 ++++++++++++++++++ |
| 9 | + .../hook/email/before_email_to_user.php | 41 +++++ |
| 10 | + lib/moodlelib.php | 39 +++++ |
| 11 | + 4 files changed, 238 insertions(+) |
| 12 | + create mode 100644 .upgradenotes/MDL-69724-2025111105250585.yml |
| 13 | + create mode 100644 lib/classes/email.php |
| 14 | + create mode 100644 lib/classes/hook/email/before_email_to_user.php |
| 15 | + |
| 16 | +diff --git a/.upgradenotes/MDL-69724-2025111105250585.yml b/.upgradenotes/MDL-69724-2025111105250585.yml |
| 17 | +new file mode 100644 |
| 18 | +index 00000000000..8d72db9e8a1 |
| 19 | +--- /dev/null |
| 20 | ++++ b/.upgradenotes/MDL-69724-2025111105250585.yml |
| 21 | +@@ -0,0 +1,9 @@ |
| 22 | ++issueNumber: MDL-69724 |
| 23 | ++notes: |
| 24 | ++ core: |
| 25 | ++ - message: >- |
| 26 | ++ `email_to_user()` now emits a hook `before_email_to_user`. This hook |
| 27 | ++ allows any subscriber to modify the email contents, add additional |
| 28 | ++ headers, or add reasons to block the email. If any block reasons are |
| 29 | ++ added, the email is stopped from being sent and the reasons are output. |
| 30 | ++ type: improved |
| 31 | +diff --git a/lib/classes/email.php b/lib/classes/email.php |
| 32 | +new file mode 100644 |
| 33 | +index 00000000000..a080a5c0feb |
| 34 | +--- /dev/null |
| 35 | ++++ b/lib/classes/email.php |
| 36 | +@@ -0,0 +1,149 @@ |
| 37 | ++<?php |
| 38 | ++// This file is part of Moodle - http://moodle.org/ |
| 39 | ++// |
| 40 | ++// Moodle is free software: you can redistribute it and/or modify |
| 41 | ++// it under the terms of the GNU General Public License as published by |
| 42 | ++// the Free Software Foundation, either version 3 of the License, or |
| 43 | ++// (at your option) any later version. |
| 44 | ++// |
| 45 | ++// Moodle is distributed in the hope that it will be useful, |
| 46 | ++// but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 47 | ++// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 48 | ++// GNU General Public License for more details. |
| 49 | ++// |
| 50 | ++// You should have received a copy of the GNU General Public License |
| 51 | ++// along with Moodle. If not, see <http://www.gnu.org/licenses/>. |
| 52 | ++ |
| 53 | ++namespace core; |
| 54 | ++ |
| 55 | ++use core\exception\coding_exception; |
| 56 | ++use stdClass; |
| 57 | ++ |
| 58 | ++/** |
| 59 | ++ * Email container class |
| 60 | ++ * |
| 61 | ++ * @package core |
| 62 | ++ * @copyright 2025 Matthew Hilton <matthewhilton@catalyst-au.net> |
| 63 | ++ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later |
| 64 | ++ */ |
| 65 | ++class email { |
| 66 | ++ /** @var array $blockreasons Reasons for this email being blocked */ |
| 67 | ++ private array $blockreasons = []; |
| 68 | ++ |
| 69 | ++ /** @var array $additionalheaders Additional email headers */ |
| 70 | ++ private array $additionalheaders = []; |
| 71 | ++ |
| 72 | ++ /** |
| 73 | ++ * Create email instance |
| 74 | ++ * |
| 75 | ++ * @param stdClass $user A $USER object |
| 76 | ++ * @param stdClass $from A $USER object |
| 77 | ++ * @param string $subject plain text subject line of the email |
| 78 | ++ * @param string $messagetext plain text version of the message |
| 79 | ++ * @param string $messagehtml complete html version of the message (optional) |
| 80 | ++ * @param string $attachment a file on the filesystem, either relative to $CFG->dataroot or a full path to a file in one of |
| 81 | ++ * the following directories: $CFG->cachedir, $CFG->dataroot, $CFG->dirroot, $CFG->localcachedir, $CFG->tempdir |
| 82 | ++ * @param string $attachname the name of the file (extension indicates MIME) |
| 83 | ++ * @param bool $usetrueaddress determines whether $from email address should |
| 84 | ++ * be sent out. Will be overruled by user profile setting for maildisplay |
| 85 | ++ * @param string $replyto Email address to reply to |
| 86 | ++ * @param string $replytoname Name of reply to recipient |
| 87 | ++ * @param int $wordwrapwidth custom word wrap width |
| 88 | ++ */ |
| 89 | ++ public function __construct( |
| 90 | ++ /** @var stdClass $user A $USER object */ |
| 91 | ++ public stdClass $user, |
| 92 | ++ /** @var stdClass $from A $USER object */ |
| 93 | ++ public stdClass $from, |
| 94 | ++ /** @var string $subject plain text subject line of the email */ |
| 95 | ++ public string $subject, |
| 96 | ++ /** @var string $messagetext plain text version of the message */ |
| 97 | ++ public string $messagetext, |
| 98 | ++ /** @var string $messagehtml complete html version of the message (optional) */ |
| 99 | ++ public string $messagehtml, |
| 100 | ++ /** @var string $attachment a file on the filesystem, either relative to $CFG->dataroot or a full path to a file in one of |
| 101 | ++ * the following directories: $CFG->cachedir, $CFG->dataroot, $CFG->dirroot, $CFG->localcachedir, $CFG->tempdir */ |
| 102 | ++ public string $attachment, |
| 103 | ++ /** @var string $attachname the name of the file (extension indicates MIME) */ |
| 104 | ++ public string $attachname, |
| 105 | ++ /** @var bool $usetrueaddress determines whether $from email address should |
| 106 | ++ * be sent out. Will be overruled by user profile setting for maildisplay */ |
| 107 | ++ public bool $usetrueaddress, |
| 108 | ++ /** @var string $replyto Email address to reply to */ |
| 109 | ++ public string $replyto, |
| 110 | ++ /** @var string $replytoname Name of reply to recipient */ |
| 111 | ++ public string $replytoname, |
| 112 | ++ /** @var int $wordwrapwidth custom word wrap width */ |
| 113 | ++ public int $wordwrapwidth, |
| 114 | ++ ) { |
| 115 | ++ // This is a quirk from email_to_user where the headers are stored in the "from" user. |
| 116 | ++ // We break them out of there here for the clarity of hook subscribers. |
| 117 | ++ $this->additionalheaders = self::extract_headers_from_from_user($from); |
| 118 | ++ |
| 119 | ++ // Remove them from $from to avoid confusion / to avoid others accidentally updating those. |
| 120 | ++ unset($from->customheaders); |
| 121 | ++ } |
| 122 | ++ |
| 123 | ++ /** |
| 124 | ++ * Extract custom headers from "from" user. |
| 125 | ++ * These may be set as a string or an array. |
| 126 | ++ * |
| 127 | ++ * @param stdClass $from |
| 128 | ++ * @return array customheaders |
| 129 | ++ */ |
| 130 | ++ private static function extract_headers_from_from_user(stdClass $from): array { |
| 131 | ++ if (!isset($from->customheaders)) { |
| 132 | ++ return []; |
| 133 | ++ } |
| 134 | ++ |
| 135 | ++ if (is_string($from->customheaders)) { |
| 136 | ++ return [$from->customheaders]; |
| 137 | ++ } |
| 138 | ++ |
| 139 | ++ if (is_array($from->customheaders)) { |
| 140 | ++ return $from->customheaders; |
| 141 | ++ } |
| 142 | ++ |
| 143 | ++ throw new coding_exception("Unknown custom headers set"); |
| 144 | ++ } |
| 145 | ++ |
| 146 | ++ /** |
| 147 | ++ * Add a reason for blocking this email. |
| 148 | ++ * @param string $reason |
| 149 | ++ */ |
| 150 | ++ public function add_block_reason(string $reason) { |
| 151 | ++ $this->blockreasons[] = $reason; |
| 152 | ++ } |
| 153 | ++ |
| 154 | ++ /** |
| 155 | ++ * Return the reasons why this email was blocked |
| 156 | ++ * @return array of strings |
| 157 | ++ */ |
| 158 | ++ public function get_block_reasons(): array { |
| 159 | ++ return $this->blockreasons; |
| 160 | ++ } |
| 161 | ++ |
| 162 | ++ /** |
| 163 | ++ * Does this email have any block reasons? |
| 164 | ++ * @return bool |
| 165 | ++ */ |
| 166 | ++ public function is_blocked(): bool { |
| 167 | ++ return !empty($this->blockreasons); |
| 168 | ++ } |
| 169 | ++ |
| 170 | ++ /** |
| 171 | ++ * Add additional header to be sent with the email |
| 172 | ++ * @param string $name header name |
| 173 | ++ */ |
| 174 | ++ public function add_additional_header(string $name) { |
| 175 | ++ $this->additionalheaders[] = $name; |
| 176 | ++ } |
| 177 | ++ |
| 178 | ++ /** |
| 179 | ++ * Return list of additional headers set |
| 180 | ++ * @return array of string values |
| 181 | ++ */ |
| 182 | ++ public function get_additional_headers(): array { |
| 183 | ++ return $this->additionalheaders; |
| 184 | ++ } |
| 185 | ++} |
| 186 | +diff --git a/lib/classes/hook/email/before_email_to_user.php b/lib/classes/hook/email/before_email_to_user.php |
| 187 | +new file mode 100644 |
| 188 | +index 00000000000..0dbcb427e1d |
| 189 | +--- /dev/null |
| 190 | ++++ b/lib/classes/hook/email/before_email_to_user.php |
| 191 | +@@ -0,0 +1,41 @@ |
| 192 | ++<?php |
| 193 | ++// This file is part of Moodle - http://moodle.org/ |
| 194 | ++// |
| 195 | ++// Moodle is free software: you can redistribute it and/or modify |
| 196 | ++// it under the terms of the GNU General Public License as published by |
| 197 | ++// the Free Software Foundation, either version 3 of the License, or |
| 198 | ++// (at your option) any later version. |
| 199 | ++// |
| 200 | ++// Moodle is distributed in the hope that it will be useful, |
| 201 | ++// but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 202 | ++// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 203 | ++// GNU General Public License for more details. |
| 204 | ++// |
| 205 | ++// You should have received a copy of the GNU General Public License |
| 206 | ++// along with Moodle. If not, see <http://www.gnu.org/licenses/>. |
| 207 | ++ |
| 208 | ++namespace core\hook\email; |
| 209 | ++ |
| 210 | ++use core\email; |
| 211 | ++ |
| 212 | ++/** |
| 213 | ++ * Hook to allow subscribers to modify or block sending email. |
| 214 | ++ * |
| 215 | ++ * @package core |
| 216 | ++ * @copyright 2025 Matthew Hilton <matthewhilton@catalyst-au.net> |
| 217 | ++ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later |
| 218 | ++ */ |
| 219 | ++#[\core\attribute\tags('email')] |
| 220 | ++#[\core\attribute\label('Allows plugins to modify contents or block sending an email')] |
| 221 | ++final class before_email_to_user { |
| 222 | ++ /** |
| 223 | ++ * Hook to allow subscribers to modify or block sending email. |
| 224 | ++ * |
| 225 | ++ * @param email $email The email message that is attempting to be sent. |
| 226 | ++ */ |
| 227 | ++ public function __construct( |
| 228 | ++ /** @var email $email The email message that is attempting to be sent. */ |
| 229 | ++ public email $email, |
| 230 | ++ ) { |
| 231 | ++ } |
| 232 | ++} |
| 233 | +diff --git a/lib/moodlelib.php b/lib/moodlelib.php |
| 234 | +index b734820bffc..289eef1a74d 100644 |
| 235 | +--- a/lib/moodlelib.php |
| 236 | ++++ b/lib/moodlelib.php |
| 237 | +@@ -29,7 +29,9 @@ |
| 238 | + */ |
| 239 | + |
| 240 | + use core\di; |
| 241 | ++use core\email; |
| 242 | + use core\hook; |
| 243 | ++use core\hook\email\before_email_to_user; |
| 244 | + |
| 245 | + defined('MOODLE_INTERNAL') || die(); |
| 246 | + |
| 247 | +@@ -5580,6 +5582,43 @@ function email_to_user($user, $from, $subject, $messagetext, $messagehtml = '', |
| 248 | + |
| 249 | + global $CFG, $PAGE, $SITE; |
| 250 | + |
| 251 | ++ // Emit email to hook subscribers, who may modify the email. |
| 252 | ++ $email = new email( |
| 253 | ++ $user, |
| 254 | ++ $from, |
| 255 | ++ $subject, |
| 256 | ++ $messagetext, |
| 257 | ++ $messagehtml, |
| 258 | ++ $attachment, |
| 259 | ++ $attachname, |
| 260 | ++ $usetrueaddress, |
| 261 | ++ $replyto, |
| 262 | ++ $replytoname, |
| 263 | ++ $wordwrapwidth |
| 264 | ++ ); |
| 265 | ++ $hook = new before_email_to_user($email); |
| 266 | ++ \core\di::get(\core\hook\manager::class)->dispatch($hook); |
| 267 | ++ |
| 268 | ++ // Read back out the data from the hook, as it may have been modified by a hook callback. |
| 269 | ++ $user = $hook->email->user; |
| 270 | ++ $from = $hook->email->from; |
| 271 | ++ $from->customheaders = $hook->email->get_additional_headers(); |
| 272 | ++ $subject = $hook->email->subject; |
| 273 | ++ $messagetext = $hook->email->messagetext; |
| 274 | ++ $messagehtml = $hook->email->messagehtml; |
| 275 | ++ $attachment = $hook->email->attachment; |
| 276 | ++ $attachname = $hook->email->attachname; |
| 277 | ++ $usetrueaddress = $hook->email->usetrueaddress; |
| 278 | ++ $replyto = $hook->email->replyto; |
| 279 | ++ $replytoname = $hook->email->replytoname; |
| 280 | ++ $wordwrapwidth = $hook->email->wordwrapwidth; |
| 281 | ++ |
| 282 | ++ // Allow plugins to block this email - if blocked log why. |
| 283 | ++ if ($hook->email->is_blocked()) { |
| 284 | ++ debugging("email_to_user: blocked by hook subscriber: " . implode(', ', $hook->email->get_block_reasons())); |
| 285 | ++ return false; |
| 286 | ++ } |
| 287 | ++ |
| 288 | + if (empty($user) or empty($user->id)) { |
| 289 | + debugging('Can not send email to null user', DEBUG_DEVELOPER); |
| 290 | + return false; |
| 291 | +-- |
| 292 | +2.43.0 |
| 293 | + |
0 commit comments