1<?php 2/** 3 * Classes used to send e-mails 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * @author <brion@pobox.com> 22 * @author <mail@tgries.de> 23 * @author Tim Starling 24 * @author Luke Welling lwelling@wikimedia.org 25 */ 26 27use MediaWiki\Mail\UserEmailContact; 28use MediaWiki\MediaWikiServices; 29use MediaWiki\Permissions\Authority; 30use MediaWiki\User\UserIdentity; 31 32/** 33 * This module processes the email notifications when the current page is 34 * changed. It looks up the table watchlist to find out which users are watching 35 * that page. 36 * 37 * The current implementation sends independent emails to each watching user for 38 * the following reason: 39 * 40 * - Each watching user will be notified about the page edit time expressed in 41 * his/her local time (UTC is shown additionally). To achieve this, we need to 42 * find the individual timeoffset of each watching user from the preferences.. 43 * 44 * Suggested improvement to slack down the number of sent emails: We could think 45 * of sending out bulk mails (bcc:user1,user2...) for all these users having the 46 * same timeoffset in their preferences. 47 * 48 * Visit the documentation pages under 49 * https://www.mediawiki.org/wiki/Help:Watching_pages 50 * 51 * TODO use UserOptionsLookup and other services, consider converting this to a service 52 */ 53class EmailNotification { 54 55 /** 56 * Notification is due to user's user talk being edited 57 */ 58 private const USER_TALK = 'user_talk'; 59 /** 60 * Notification is due to a watchlisted page being edited 61 */ 62 private const WATCHLIST = 'watchlist'; 63 /** 64 * Notification because user is notified for all changes 65 */ 66 private const ALL_CHANGES = 'all_changes'; 67 68 /** @var string */ 69 protected $subject = ''; 70 71 /** @var string */ 72 protected $body = ''; 73 74 /** @var MailAddress|null */ 75 protected $replyto; 76 77 /** @var MailAddress|null */ 78 protected $from; 79 80 /** @var string|null */ 81 protected $timestamp; 82 83 /** @var string */ 84 protected $summary = ''; 85 86 /** @var bool|null */ 87 protected $minorEdit; 88 89 /** @var int|null|bool */ 90 protected $oldid; 91 92 /** @var bool */ 93 protected $composed_common = false; 94 95 /** @var string */ 96 protected $pageStatus = ''; 97 98 /** @var MailAddress[] */ 99 protected $mailTargets = []; 100 101 /** @var Title */ 102 protected $title; 103 104 /** @var User */ 105 protected $editor; 106 107 /** 108 * Extensions that have hooks for 109 * UpdateUserMailerFormattedPageStatus (to provide additional 110 * pageStatus indicators) need a way to make sure that, when their 111 * hook is called in SendWatchlistemailNotification, they only 112 * handle notifications using their pageStatus indicator. 113 * 114 * @since 1.33 115 * @return string 116 */ 117 public function getPageStatus() { 118 return $this->pageStatus; 119 } 120 121 /** 122 * Send emails corresponding to the user $editor editing the page $title. 123 * 124 * May be deferred via the job queue. 125 * 126 * @param Authority $editor 127 * @param Title $title 128 * @param string $timestamp 129 * @param string $summary 130 * @param bool $minorEdit 131 * @param bool $oldid (default: false) 132 * @param string $pageStatus (default: 'changed') 133 * @return bool Whether an email job was created or not. 134 * @since 1.35 returns a boolean indicating whether an email job was created. 135 */ 136 public function notifyOnPageChange( 137 Authority $editor, 138 $title, 139 $timestamp, 140 $summary, 141 $minorEdit, 142 $oldid = false, 143 $pageStatus = 'changed' 144 ): bool { 145 if ( $title->getNamespace() < 0 ) { 146 return false; 147 } 148 149 $mwServices = MediaWikiServices::getInstance(); 150 $config = $mwServices->getMainConfig(); 151 152 // update wl_notificationtimestamp for watchers 153 $watchers = []; 154 if ( $config->get( 'EnotifWatchlist' ) || $config->get( 'ShowUpdatedMarker' ) ) { 155 $watchers = $mwServices->getWatchedItemStore()->updateNotificationTimestamp( 156 $editor->getUser(), 157 $title, 158 $timestamp 159 ); 160 } 161 162 $sendEmail = true; 163 // $watchers deals with $wgEnotifWatchlist. 164 // If nobody is watching the page, and there are no users notified on all changes 165 // don't bother creating a job/trying to send emails, unless it's a 166 // talk page with an applicable notification. 167 if ( $watchers === [] && !count( $config->get( 'UsersNotifiedOnAllChanges' ) ) ) { 168 $sendEmail = false; 169 // Only send notification for non minor edits, unless $wgEnotifMinorEdits 170 if ( !$minorEdit || 171 ( $config->get( 'EnotifMinorEdits' ) && !$editor->isAllowed( 'nominornewtalk' ) ) 172 ) { 173 $isUserTalkPage = ( $title->getNamespace() === NS_USER_TALK ); 174 if ( $config->get( 'EnotifUserTalk' ) 175 && $isUserTalkPage 176 && $this->canSendUserTalkEmail( $editor->getUser(), $title, $minorEdit ) 177 ) { 178 $sendEmail = true; 179 } 180 } 181 } 182 183 if ( $sendEmail ) { 184 JobQueueGroup::singleton()->lazyPush( new EnotifNotifyJob( 185 $title, 186 [ 187 'editor' => $editor->getUser()->getName(), 188 'editorID' => $editor->getUser()->getId(), 189 'timestamp' => $timestamp, 190 'summary' => $summary, 191 'minorEdit' => $minorEdit, 192 'oldid' => $oldid, 193 'watchers' => $watchers, 194 'pageStatus' => $pageStatus 195 ] 196 ) ); 197 } 198 199 return $sendEmail; 200 } 201 202 /** 203 * Immediate version of notifyOnPageChange(). 204 * 205 * Send emails corresponding to the user $editor editing the page $title. 206 * 207 * @note Do not call directly. Use notifyOnPageChange so that wl_notificationtimestamp is updated. 208 * @param Authority $editor 209 * @param Title $title 210 * @param string $timestamp Edit timestamp 211 * @param string $summary Edit summary 212 * @param bool $minorEdit 213 * @param int $oldid Revision ID 214 * @param array $watchers Array of user IDs 215 * @param string $pageStatus 216 * @throws MWException 217 */ 218 public function actuallyNotifyOnPageChange( 219 Authority $editor, 220 $title, 221 $timestamp, 222 $summary, 223 $minorEdit, 224 $oldid, 225 $watchers, 226 $pageStatus = 'changed' 227 ) { 228 # we use $wgPasswordSender as sender's address 229 230 $mwServices = MediaWikiServices::getInstance(); 231 $messageCache = $mwServices->getMessageCache(); 232 $config = $mwServices->getMainConfig(); 233 234 # The following code is only run, if several conditions are met: 235 # 1. EmailNotification for pages (other than user_talk pages) must be enabled 236 # 2. minor edits (changes) are only regarded if the global flag indicates so 237 238 $isUserTalkPage = ( $title->getNamespace() === NS_USER_TALK ); 239 240 $this->title = $title; 241 $this->timestamp = $timestamp; 242 $this->summary = $summary; 243 $this->minorEdit = $minorEdit; 244 $this->oldid = $oldid; 245 $this->editor = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $editor ); 246 $this->composed_common = false; 247 $this->pageStatus = $pageStatus; 248 249 $formattedPageStatus = [ 'deleted', 'created', 'moved', 'restored', 'changed' ]; 250 251 Hooks::runner()->onUpdateUserMailerFormattedPageStatus( $formattedPageStatus ); 252 if ( !in_array( $this->pageStatus, $formattedPageStatus ) ) { 253 throw new MWException( 'Not a valid page status!' ); 254 } 255 256 $userTalkId = false; 257 258 if ( !$minorEdit || 259 ( $config->get( 'EnotifMinorEdits' ) && !$editor->isAllowed( 'nominornewtalk' ) ) 260 ) { 261 if ( $config->get( 'EnotifUserTalk' ) 262 && $isUserTalkPage 263 && $this->canSendUserTalkEmail( $editor->getUser(), $title, $minorEdit ) 264 ) { 265 $targetUser = User::newFromName( $title->getText() ); 266 $this->compose( $targetUser, self::USER_TALK, $messageCache ); 267 $userTalkId = $targetUser->getId(); 268 } 269 270 if ( $config->get( 'EnotifWatchlist' ) ) { 271 // Send updates to watchers other than the current editor 272 // and don't send to watchers who are blocked and cannot login 273 $userArray = UserArray::newFromIDs( $watchers ); 274 foreach ( $userArray as $watchingUser ) { 275 if ( $watchingUser->getOption( 'enotifwatchlistpages' ) 276 && ( !$minorEdit || $watchingUser->getOption( 'enotifminoredits' ) ) 277 && $watchingUser->isEmailConfirmed() 278 && $watchingUser->getId() != $userTalkId 279 && !in_array( $watchingUser->getName(), $config->get( 'UsersNotifiedOnAllChanges' ) ) 280 // @TODO Partial blocks should not prevent the user from logging in. 281 // see: https://phabricator.wikimedia.org/T208895 282 && !( $config->get( 'BlockDisablesLogin' ) && $watchingUser->getBlock() ) 283 && Hooks::runner()->onSendWatchlistEmailNotification( $watchingUser, $title, $this ) 284 ) { 285 $this->compose( $watchingUser, self::WATCHLIST, $messageCache ); 286 } 287 } 288 } 289 } 290 291 foreach ( $config->get( 'UsersNotifiedOnAllChanges' ) as $name ) { 292 if ( $editor->getUser()->getName() == $name ) { 293 // No point notifying the user that actually made the change! 294 continue; 295 } 296 $user = User::newFromName( $name ); 297 $this->compose( $user, self::ALL_CHANGES, $messageCache ); 298 } 299 300 $this->sendMails(); 301 } 302 303 /** 304 * @param UserIdentity $editor 305 * @param Title $title 306 * @param bool $minorEdit 307 * @return bool 308 */ 309 private function canSendUserTalkEmail( UserIdentity $editor, $title, $minorEdit ) { 310 $config = MediaWikiServices::getInstance()->getMainConfig(); 311 $isUserTalkPage = ( $title->getNamespace() === NS_USER_TALK ); 312 313 if ( !$config->get( 'EnotifUserTalk' ) || !$isUserTalkPage ) { 314 return false; 315 } 316 317 $targetUser = User::newFromName( $title->getText() ); 318 319 if ( !$targetUser || $targetUser->isAnon() ) { 320 wfDebug( __METHOD__ . ": user talk page edited, but user does not exist" ); 321 } elseif ( $targetUser->getId() == $editor->getId() ) { 322 wfDebug( __METHOD__ . ": user edited their own talk page, no notification sent" ); 323 } elseif ( $config->get( 'BlockDisablesLogin' ) && $targetUser->getBlock() ) { 324 // @TODO Partial blocks should not prevent the user from logging in. 325 // see: https://phabricator.wikimedia.org/T208895 326 wfDebug( __METHOD__ . ": talk page owner is blocked and cannot login, no notification sent" ); 327 } elseif ( $targetUser->getOption( 'enotifusertalkpages' ) 328 && ( !$minorEdit || $targetUser->getOption( 'enotifminoredits' ) ) 329 ) { 330 if ( !$targetUser->isEmailConfirmed() ) { 331 wfDebug( __METHOD__ . ": talk page owner doesn't have validated email" ); 332 } elseif ( !Hooks::runner()->onAbortTalkPageEmailNotification( $targetUser, $title ) ) { 333 wfDebug( __METHOD__ . ": talk page update notification is aborted for this user" ); 334 } else { 335 wfDebug( __METHOD__ . ": sending talk page update notification" ); 336 return true; 337 } 338 } else { 339 wfDebug( __METHOD__ . ": talk page owner doesn't want notifications" ); 340 } 341 return false; 342 } 343 344 /** 345 * Generate the generic "this page has been changed" e-mail text. 346 * @param MessageCache $messageCache 347 */ 348 private function composeCommonMailtext( MessageCache $messageCache ) { 349 $config = MediaWikiServices::getInstance()->getMainConfig(); 350 351 $this->composed_common = true; 352 353 # You as the WikiAdmin and Sysops can make use of plenty of 354 # named variables when composing your notification emails while 355 # simply editing the Meta pages 356 357 $keys = []; 358 $postTransformKeys = []; 359 $pageTitleUrl = $this->title->getCanonicalURL(); 360 $pageTitle = $this->title->getPrefixedText(); 361 362 if ( $this->oldid ) { 363 // Always show a link to the diff which triggered the mail. See T34210. 364 $keys['$NEWPAGE'] = "\n\n" . wfMessage( 365 'enotif_lastdiff', 366 $this->title->getCanonicalURL( [ 'diff' => 'next', 'oldid' => $this->oldid ] ) 367 )->inContentLanguage()->text(); 368 369 if ( !$config->get( 'EnotifImpersonal' ) ) { 370 // For personal mail, also show a link to the diff of all changes 371 // since last visited. 372 $keys['$NEWPAGE'] .= "\n\n" . wfMessage( 373 'enotif_lastvisited', 374 $this->title->getCanonicalURL( [ 'diff' => '0', 'oldid' => $this->oldid ] ) 375 )->inContentLanguage()->text(); 376 } 377 $keys['$OLDID'] = $this->oldid; 378 // Deprecated since MediaWiki 1.21, not used by default. Kept for backwards-compatibility. 379 $keys['$CHANGEDORCREATED'] = wfMessage( 'changed' )->inContentLanguage()->text(); 380 } else { 381 # clear $OLDID placeholder in the message template 382 $keys['$OLDID'] = ''; 383 $keys['$NEWPAGE'] = ''; 384 // Deprecated since MediaWiki 1.21, not used by default. Kept for backwards-compatibility. 385 $keys['$CHANGEDORCREATED'] = wfMessage( 'created' )->inContentLanguage()->text(); 386 } 387 388 $keys['$PAGETITLE'] = $this->title->getPrefixedText(); 389 $keys['$PAGETITLE_URL'] = $this->title->getCanonicalURL(); 390 $keys['$PAGEMINOREDIT'] = $this->minorEdit ? 391 "\n\n" . wfMessage( 'enotif_minoredit' )->inContentLanguage()->text() : 392 ''; 393 $keys['$UNWATCHURL'] = $this->title->getCanonicalURL( 'action=unwatch' ); 394 395 if ( !$this->editor->isRegistered() ) { 396 # real anon (user:xxx.xxx.xxx.xxx) 397 $keys['$PAGEEDITOR'] = wfMessage( 'enotif_anon_editor', $this->editor->getName() ) 398 ->inContentLanguage()->text(); 399 $keys['$PAGEEDITOR_EMAIL'] = wfMessage( 'noemailtitle' )->inContentLanguage()->text(); 400 401 } else { 402 $keys['$PAGEEDITOR'] = $config->get( 'EnotifUseRealName' ) && $this->editor->getRealName() !== '' 403 ? $this->editor->getRealName() : $this->editor->getName(); 404 $emailPage = SpecialPage::getSafeTitleFor( 'Emailuser', $this->editor->getName() ); 405 $keys['$PAGEEDITOR_EMAIL'] = $emailPage->getCanonicalURL(); 406 } 407 408 $keys['$PAGEEDITOR_WIKI'] = $this->editor->getUserPage()->getCanonicalURL(); 409 $keys['$HELPPAGE'] = wfExpandUrl( 410 Skin::makeInternalOrExternalUrl( wfMessage( 'helppage' )->inContentLanguage()->text() ) 411 ); 412 413 # Replace this after transforming the message, T37019 414 $postTransformKeys['$PAGESUMMARY'] = $this->summary == '' ? ' - ' : $this->summary; 415 416 // Now build message's subject and body 417 418 // Messages: 419 // enotif_subject_deleted, enotif_subject_created, enotif_subject_moved, 420 // enotif_subject_restored, enotif_subject_changed 421 $this->subject = wfMessage( 'enotif_subject_' . $this->pageStatus )->inContentLanguage() 422 ->params( $pageTitle, $keys['$PAGEEDITOR'] )->text(); 423 424 // Messages: 425 // enotif_body_intro_deleted, enotif_body_intro_created, enotif_body_intro_moved, 426 // enotif_body_intro_restored, enotif_body_intro_changed 427 $keys['$PAGEINTRO'] = wfMessage( 'enotif_body_intro_' . $this->pageStatus ) 428 ->inContentLanguage() 429 ->params( $pageTitle, $keys['$PAGEEDITOR'], $pageTitleUrl ) 430 ->text(); 431 432 $body = wfMessage( 'enotif_body' )->inContentLanguage()->plain(); 433 $body = strtr( $body, $keys ); 434 $body = $messageCache->transform( $body, false, null, $this->title ); 435 $this->body = wordwrap( strtr( $body, $postTransformKeys ), 72 ); 436 437 # Reveal the page editor's address as REPLY-TO address only if 438 # the user has not opted-out and the option is enabled at the 439 # global configuration level. 440 $adminAddress = new MailAddress( 441 $config->get( 'PasswordSender' ), 442 wfMessage( 'emailsender' )->inContentLanguage()->text() 443 ); 444 if ( $config->get( 'EnotifRevealEditorAddress' ) 445 && ( $this->editor->getEmail() != '' ) 446 && $this->editor->getOption( 'enotifrevealaddr' ) 447 ) { 448 $editorAddress = MailAddress::newFromUser( $this->editor ); 449 if ( $config->get( 'EnotifFromEditor' ) ) { 450 $this->from = $editorAddress; 451 } else { 452 $this->from = $adminAddress; 453 $this->replyto = $editorAddress; 454 } 455 } else { 456 $this->from = $adminAddress; 457 $this->replyto = new MailAddress( 458 $config->get( 'NoReplyAddress' ) 459 ); 460 } 461 } 462 463 /** 464 * Compose a mail to a given user and either queue it for sending, or send it now, 465 * depending on settings. 466 * 467 * Call sendMails() to send any mails that were queued. 468 * @param UserEmailContact $user 469 * @param string $source 470 * @param MessageCache $messageCache 471 */ 472 private function compose( UserEmailContact $user, $source, MessageCache $messageCache ) { 473 if ( !$this->composed_common ) { 474 $this->composeCommonMailtext( $messageCache ); 475 } 476 477 if ( MediaWikiServices::getInstance()->getMainConfig()->get( 'EnotifImpersonal' ) ) { 478 $this->mailTargets[] = MailAddress::newFromUser( $user ); 479 } else { 480 $this->sendPersonalised( $user, $source ); 481 } 482 } 483 484 /** 485 * Send any queued mails 486 */ 487 private function sendMails() { 488 if ( MediaWikiServices::getInstance()->getMainConfig()->get( 'EnotifImpersonal' ) ) { 489 $this->sendImpersonal( $this->mailTargets ); 490 } 491 } 492 493 /** 494 * Does the per-user customizations to a notification e-mail (name, 495 * timestamp in proper timezone, etc) and sends it out. 496 * Returns Status if email was sent successfully or not (Status::newGood() 497 * or Status::newFatal() respectively). 498 * 499 * @param UserEmailContact $watchingUser 500 * @param string $source 501 * @return Status 502 */ 503 private function sendPersonalised( UserEmailContact $watchingUser, $source ) { 504 // From the PHP manual: 505 // Note: The to parameter cannot be an address in the form of 506 // "Something <someone@example.com>". The mail command will not parse 507 // this properly while talking with the MTA. 508 $to = MailAddress::newFromUser( $watchingUser ); 509 510 # $PAGEEDITDATE is the time and date of the page change 511 # expressed in terms of individual local time of the notification 512 # recipient, i.e. watching user 513 $mwServices = MediaWikiServices::getInstance(); 514 $contLang = $mwServices->getContentLanguage(); 515 $watchingUserName = ( 516 $mwServices->getMainConfig()->get( 'EnotifUseRealName' ) && 517 $watchingUser->getRealName() !== '' 518 ) ? $watchingUser->getRealName() : $watchingUser->getUser()->getName(); 519 $body = str_replace( 520 [ 521 '$WATCHINGUSERNAME', 522 '$PAGEEDITDATE', 523 '$PAGEEDITTIME' 524 ], 525 [ 526 $watchingUserName, 527 $contLang->userDate( $this->timestamp, $watchingUser->getUser() ), 528 $contLang->userTime( $this->timestamp, $watchingUser->getUser() ) 529 ], 530 $this->body 531 ); 532 533 $headers = []; 534 if ( $source === self::WATCHLIST ) { 535 $headers['List-Help'] = 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Watchlist'; 536 } 537 538 return UserMailer::send( $to, $this->from, $this->subject, $body, [ 539 'replyTo' => $this->replyto, 540 'headers' => $headers, 541 ] ); 542 } 543 544 /** 545 * Same as sendPersonalised but does impersonal mail suitable for bulk 546 * mailing. Takes an array of MailAddress objects. 547 * @param MailAddress[] $addresses 548 * @return Status|null 549 */ 550 private function sendImpersonal( $addresses ) { 551 if ( empty( $addresses ) ) { 552 return null; 553 } 554 555 $contLang = MediaWikiServices::getInstance()->getContentLanguage(); 556 $body = str_replace( 557 [ 558 '$WATCHINGUSERNAME', 559 '$PAGEEDITDATE', 560 '$PAGEEDITTIME' 561 ], 562 [ 563 wfMessage( 'enotif_impersonal_salutation' )->inContentLanguage()->text(), 564 $contLang->date( $this->timestamp, false, false ), 565 $contLang->time( $this->timestamp, false, false ) 566 ], 567 $this->body 568 ); 569 570 return UserMailer::send( $addresses, $this->from, $this->subject, $body, [ 571 'replyTo' => $this->replyto, 572 ] ); 573 } 574 575} 576