SpecialEmailUser.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. <?php
  2. /**
  3. * Implements Special:Emailuser
  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. * @ingroup SpecialPage
  22. */
  23. use MediaWiki\MediaWikiServices;
  24. use MediaWiki\Preferences\MultiUsernameFilter;
  25. /**
  26. * A special page that allows users to send e-mails to other users
  27. *
  28. * @ingroup SpecialPage
  29. */
  30. class SpecialEmailUser extends UnlistedSpecialPage {
  31. protected $mTarget;
  32. /**
  33. * @var User|string $mTargetObj
  34. */
  35. protected $mTargetObj;
  36. public function __construct() {
  37. parent::__construct( 'Emailuser' );
  38. }
  39. public function doesWrites() {
  40. return true;
  41. }
  42. public function getDescription() {
  43. $target = self::getTarget( $this->mTarget, $this->getUser() );
  44. if ( !$target instanceof User ) {
  45. return $this->msg( 'emailuser-title-notarget' )->text();
  46. }
  47. return $this->msg( 'emailuser-title-target', $target->getName() )->text();
  48. }
  49. protected function getFormFields() {
  50. $linkRenderer = $this->getLinkRenderer();
  51. return [
  52. 'From' => [
  53. 'type' => 'info',
  54. 'raw' => 1,
  55. 'default' => $linkRenderer->makeLink(
  56. $this->getUser()->getUserPage(),
  57. $this->getUser()->getName()
  58. ),
  59. 'label-message' => 'emailfrom',
  60. 'id' => 'mw-emailuser-sender',
  61. ],
  62. 'To' => [
  63. 'type' => 'info',
  64. 'raw' => 1,
  65. 'default' => $linkRenderer->makeLink(
  66. $this->mTargetObj->getUserPage(),
  67. $this->mTargetObj->getName()
  68. ),
  69. 'label-message' => 'emailto',
  70. 'id' => 'mw-emailuser-recipient',
  71. ],
  72. 'Target' => [
  73. 'type' => 'hidden',
  74. 'default' => $this->mTargetObj->getName(),
  75. ],
  76. 'Subject' => [
  77. 'type' => 'text',
  78. 'default' => $this->msg( 'defemailsubject',
  79. $this->getUser()->getName() )->inContentLanguage()->text(),
  80. 'label-message' => 'emailsubject',
  81. 'maxlength' => 200,
  82. 'size' => 60,
  83. 'required' => true,
  84. ],
  85. 'Text' => [
  86. 'type' => 'textarea',
  87. 'rows' => 20,
  88. 'label-message' => 'emailmessage',
  89. 'required' => true,
  90. ],
  91. 'CCMe' => [
  92. 'type' => 'check',
  93. 'label-message' => 'emailccme',
  94. 'default' => $this->getUser()->getBoolOption( 'ccmeonemails' ),
  95. ],
  96. ];
  97. }
  98. public function execute( $par ) {
  99. $out = $this->getOutput();
  100. $request = $this->getRequest();
  101. $out->addModuleStyles( 'mediawiki.special' );
  102. $this->mTarget = $par ?? $request->getVal( 'wpTarget', $request->getVal( 'target', '' ) );
  103. // Make sure, that HTMLForm uses the correct target.
  104. $request->setVal( 'wpTarget', $this->mTarget );
  105. // This needs to be below assignment of $this->mTarget because
  106. // getDescription() needs it to determine the correct page title.
  107. $this->setHeaders();
  108. $this->outputHeader();
  109. // error out if sending user cannot do this
  110. $error = self::getPermissionsError(
  111. $this->getUser(),
  112. $this->getRequest()->getVal( 'wpEditToken' ),
  113. $this->getConfig()
  114. );
  115. switch ( $error ) {
  116. case null:
  117. # Wahey!
  118. break;
  119. case 'badaccess':
  120. throw new PermissionsError( 'sendemail' );
  121. case 'blockedemailuser':
  122. throw $this->getBlockedEmailError();
  123. case 'actionthrottledtext':
  124. throw new ThrottledError;
  125. case 'mailnologin':
  126. case 'usermaildisabled':
  127. throw new ErrorPageError( $error, "{$error}text" );
  128. default:
  129. # It's a hook error
  130. list( $title, $msg, $params ) = $error;
  131. throw new ErrorPageError( $title, $msg, $params );
  132. }
  133. // Make sure, that a submitted form isn't submitted to a subpage (which could be
  134. // a non-existing username)
  135. $context = new DerivativeContext( $this->getContext() );
  136. $context->setTitle( $this->getPageTitle() ); // Remove subpage
  137. $this->setContext( $context );
  138. // A little hack: HTMLForm will check $this->mTarget only, if the form was posted, not
  139. // if the user opens Special:EmailUser/Florian (e.g.). So check, if the user did that
  140. // and show the "Send email to user" form directly, if so. Show the "enter username"
  141. // form, otherwise.
  142. $this->mTargetObj = self::getTarget( $this->mTarget, $this->getUser() );
  143. if ( !$this->mTargetObj instanceof User ) {
  144. $this->userForm( $this->mTarget );
  145. } else {
  146. $this->sendEmailForm();
  147. }
  148. }
  149. /**
  150. * Validate target User
  151. *
  152. * @param string $target Target user name
  153. * @param User $sender User sending the email
  154. * @return User|string User object on success or a string on error
  155. */
  156. public static function getTarget( $target, User $sender ) {
  157. if ( $target == '' ) {
  158. wfDebug( "Target is empty.\n" );
  159. return 'notarget';
  160. }
  161. $nu = User::newFromName( $target );
  162. $error = self::validateTarget( $nu, $sender );
  163. return $error ?: $nu;
  164. }
  165. /**
  166. * Validate target User
  167. *
  168. * @param User $target Target user
  169. * @param User $sender User sending the email
  170. * @return string Error message or empty string if valid.
  171. * @since 1.30
  172. */
  173. public static function validateTarget( $target, User $sender ) {
  174. if ( !$target instanceof User || !$target->getId() ) {
  175. wfDebug( "Target is invalid user.\n" );
  176. return 'notarget';
  177. }
  178. if ( !$target->isEmailConfirmed() ) {
  179. wfDebug( "User has no valid email.\n" );
  180. return 'noemail';
  181. }
  182. if ( !$target->canReceiveEmail() ) {
  183. wfDebug( "User does not allow user emails.\n" );
  184. return 'nowikiemail';
  185. }
  186. if ( !$target->getOption( 'email-allow-new-users' ) && $sender->isNewbie() ) {
  187. wfDebug( "User does not allow user emails from new users.\n" );
  188. return 'nowikiemail';
  189. }
  190. $blacklist = $target->getOption( 'email-blacklist', '' );
  191. if ( $blacklist ) {
  192. $blacklist = MultiUsernameFilter::splitIds( $blacklist );
  193. $lookup = CentralIdLookup::factory();
  194. $senderId = $lookup->centralIdFromLocalUser( $sender );
  195. if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) {
  196. wfDebug( "User does not allow user emails from this user.\n" );
  197. return 'nowikiemail';
  198. }
  199. }
  200. return '';
  201. }
  202. /**
  203. * Check whether a user is allowed to send email
  204. *
  205. * @param User $user
  206. * @param string $editToken Edit token
  207. * @param Config|null $config optional for backwards compatibility
  208. * @return null|string|array Null on success, string on error, or array on
  209. * hook error
  210. */
  211. public static function getPermissionsError( $user, $editToken, Config $config = null ) {
  212. if ( $config === null ) {
  213. wfDebug( __METHOD__ . ' called without a Config instance passed to it' );
  214. $config = MediaWikiServices::getInstance()->getMainConfig();
  215. }
  216. if ( !$config->get( 'EnableEmail' ) || !$config->get( 'EnableUserEmail' ) ) {
  217. return 'usermaildisabled';
  218. }
  219. // Run this before $user->isAllowed, to show appropriate message to anons (T160309)
  220. if ( !$user->isEmailConfirmed() ) {
  221. return 'mailnologin';
  222. }
  223. if ( !MediaWikiServices::getInstance()
  224. ->getPermissionManager()
  225. ->userHasRight( $user, 'sendemail' )
  226. ) {
  227. return 'badaccess';
  228. }
  229. if ( $user->isBlockedFromEmailuser() ) {
  230. wfDebug( "User is blocked from sending e-mail.\n" );
  231. return "blockedemailuser";
  232. }
  233. // Check the ping limiter without incrementing it - we'll check it
  234. // again later and increment it on a successful send
  235. if ( $user->pingLimiter( 'emailuser', 0 ) ) {
  236. wfDebug( "Ping limiter triggered.\n" );
  237. return 'actionthrottledtext';
  238. }
  239. $hookErr = false;
  240. Hooks::run( 'UserCanSendEmail', [ &$user, &$hookErr ] );
  241. Hooks::run( 'EmailUserPermissionsErrors', [ $user, $editToken, &$hookErr ] );
  242. if ( $hookErr ) {
  243. return $hookErr;
  244. }
  245. return null;
  246. }
  247. /**
  248. * Form to ask for target user name.
  249. *
  250. * @param string $name User name submitted.
  251. */
  252. protected function userForm( $name ) {
  253. $htmlForm = HTMLForm::factory( 'ooui', [
  254. 'Target' => [
  255. 'type' => 'user',
  256. 'exists' => true,
  257. 'label' => $this->msg( 'emailusername' )->text(),
  258. 'id' => 'emailusertarget',
  259. 'autofocus' => true,
  260. 'value' => $name,
  261. ]
  262. ], $this->getContext() );
  263. $htmlForm
  264. ->setMethod( 'post' )
  265. ->setSubmitCallback( [ $this, 'sendEmailForm' ] )
  266. ->setFormIdentifier( 'userForm' )
  267. ->setId( 'askusername' )
  268. ->setWrapperLegendMsg( 'emailtarget' )
  269. ->setSubmitTextMsg( 'emailusernamesubmit' )
  270. ->show();
  271. }
  272. public function sendEmailForm() {
  273. $out = $this->getOutput();
  274. $ret = $this->mTargetObj;
  275. if ( !$ret instanceof User ) {
  276. if ( $this->mTarget != '' ) {
  277. // Messages used here: notargettext, noemailtext, nowikiemailtext
  278. $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' );
  279. return Status::newFatal( $ret );
  280. }
  281. return false;
  282. }
  283. $htmlForm = HTMLForm::factory( 'ooui', $this->getFormFields(), $this->getContext() );
  284. // By now we are supposed to be sure that $this->mTarget is a user name
  285. $htmlForm
  286. ->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() )
  287. ->setSubmitTextMsg( 'emailsend' )
  288. ->setSubmitCallback( [ __CLASS__, 'submit' ] )
  289. ->setFormIdentifier( 'sendEmailForm' )
  290. ->setWrapperLegendMsg( 'email-legend' )
  291. ->loadData();
  292. if ( !Hooks::run( 'EmailUserForm', [ &$htmlForm ] ) ) {
  293. return false;
  294. }
  295. $result = $htmlForm->show();
  296. if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
  297. $out->setPageTitle( $this->msg( 'emailsent' ) );
  298. $out->addWikiMsg( 'emailsenttext', $this->mTarget );
  299. $out->returnToMain( false, $ret->getUserPage() );
  300. }
  301. return true;
  302. }
  303. /**
  304. * Really send a mail. Permissions should have been checked using
  305. * getPermissionsError(). It is probably also a good
  306. * idea to check the edit token and ping limiter in advance.
  307. *
  308. * @param array $data
  309. * @param IContextSource $context
  310. * @return Status|bool
  311. */
  312. public static function submit( array $data, IContextSource $context ) {
  313. $config = $context->getConfig();
  314. $target = self::getTarget( $data['Target'], $context->getUser() );
  315. if ( !$target instanceof User ) {
  316. // Messages used here: notargettext, noemailtext, nowikiemailtext
  317. return Status::newFatal( $target . 'text' );
  318. }
  319. $to = MailAddress::newFromUser( $target );
  320. $from = MailAddress::newFromUser( $context->getUser() );
  321. $subject = $data['Subject'];
  322. $text = $data['Text'];
  323. // Add a standard footer and trim up trailing newlines
  324. $text = rtrim( $text ) . "\n\n-- \n";
  325. $text .= $context->msg( 'emailuserfooter',
  326. $from->name, $to->name )->inContentLanguage()->text();
  327. if ( $config->get( 'EnableSpecialMute' ) ) {
  328. $specialMutePage = SpecialPage::getTitleFor( 'Mute', $context->getUser()->getName() );
  329. $text .= "\n" . $context->msg(
  330. 'specialmute-email-footer',
  331. $specialMutePage->getCanonicalURL(),
  332. $context->getUser()->getName()
  333. )->inContentLanguage()->text();
  334. }
  335. // Check and increment the rate limits
  336. if ( $context->getUser()->pingLimiter( 'emailuser' ) ) {
  337. throw new ThrottledError();
  338. }
  339. $error = false;
  340. if ( !Hooks::run( 'EmailUser', [ &$to, &$from, &$subject, &$text, &$error ] ) ) {
  341. if ( $error instanceof Status ) {
  342. return $error;
  343. } elseif ( $error === false || $error === '' || $error === [] ) {
  344. // Possibly to tell HTMLForm to pretend there was no submission?
  345. return false;
  346. } elseif ( $error === true ) {
  347. // Hook sent the mail itself and indicates success?
  348. return Status::newGood();
  349. } elseif ( is_array( $error ) ) {
  350. $status = Status::newGood();
  351. foreach ( $error as $e ) {
  352. $status->fatal( $e );
  353. }
  354. return $status;
  355. } elseif ( $error instanceof MessageSpecifier ) {
  356. return Status::newFatal( $error );
  357. } else {
  358. // Ugh. Either a raw HTML string, or something that's supposed
  359. // to be treated like one.
  360. $type = is_object( $error ) ? get_class( $error ) : gettype( $error );
  361. wfDeprecated( "EmailUser hook returning a $type as \$error", '1.29' );
  362. return Status::newFatal( new ApiRawMessage(
  363. [ '$1', Message::rawParam( (string)$error ) ], 'hookaborted'
  364. ) );
  365. }
  366. }
  367. if ( $config->get( 'UserEmailUseReplyTo' ) ) {
  368. /**
  369. * Put the generic wiki autogenerated address in the From:
  370. * header and reserve the user for Reply-To.
  371. *
  372. * This is a bit ugly, but will serve to differentiate
  373. * wiki-borne mails from direct mails and protects against
  374. * SPF and bounce problems with some mailers (see below).
  375. */
  376. $mailFrom = new MailAddress( $config->get( 'PasswordSender' ),
  377. $context->msg( 'emailsender' )->inContentLanguage()->text() );
  378. $replyTo = $from;
  379. } else {
  380. /**
  381. * Put the sending user's e-mail address in the From: header.
  382. *
  383. * This is clean-looking and convenient, but has issues.
  384. * One is that it doesn't as clearly differentiate the wiki mail
  385. * from "directly" sent mails.
  386. *
  387. * Another is that some mailers (like sSMTP) will use the From
  388. * address as the envelope sender as well. For open sites this
  389. * can cause mails to be flunked for SPF violations (since the
  390. * wiki server isn't an authorized sender for various users'
  391. * domains) as well as creating a privacy issue as bounces
  392. * containing the recipient's e-mail address may get sent to
  393. * the sending user.
  394. */
  395. $mailFrom = $from;
  396. $replyTo = null;
  397. }
  398. $status = UserMailer::send( $to, $mailFrom, $subject, $text, [
  399. 'replyTo' => $replyTo,
  400. ] );
  401. if ( !$status->isGood() ) {
  402. return $status;
  403. } else {
  404. // if the user requested a copy of this mail, do this now,
  405. // unless they are emailing themselves, in which case one
  406. // copy of the message is sufficient.
  407. if ( $data['CCMe'] && $to != $from ) {
  408. $ccTo = $from;
  409. $ccFrom = $from;
  410. $ccSubject = $context->msg( 'emailccsubject' )->plaintextParams(
  411. $target->getName(), $subject )->text();
  412. $ccText = $text;
  413. Hooks::run( 'EmailUserCC', [ &$ccTo, &$ccFrom, &$ccSubject, &$ccText ] );
  414. if ( $config->get( 'UserEmailUseReplyTo' ) ) {
  415. $mailFrom = new MailAddress(
  416. $config->get( 'PasswordSender' ),
  417. $context->msg( 'emailsender' )->inContentLanguage()->text()
  418. );
  419. $replyTo = $ccFrom;
  420. } else {
  421. $mailFrom = $ccFrom;
  422. $replyTo = null;
  423. }
  424. $ccStatus = UserMailer::send(
  425. $ccTo, $mailFrom, $ccSubject, $ccText, [
  426. 'replyTo' => $replyTo,
  427. ] );
  428. $status->merge( $ccStatus );
  429. }
  430. Hooks::run( 'EmailUserComplete', [ $to, $from, $subject, $text ] );
  431. return $status;
  432. }
  433. }
  434. /**
  435. * Return an array of subpages beginning with $search that this special page will accept.
  436. *
  437. * @param string $search Prefix to search for
  438. * @param int $limit Maximum number of results to return (usually 10)
  439. * @param int $offset Number of results to skip (usually 0)
  440. * @return string[] Matching subpages
  441. */
  442. public function prefixSearchSubpages( $search, $limit, $offset ) {
  443. $user = User::newFromName( $search );
  444. if ( !$user ) {
  445. // No prefix suggestion for invalid user
  446. return [];
  447. }
  448. // Autocomplete subpage as user list - public to allow caching
  449. return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
  450. }
  451. protected function getGroupName() {
  452. return 'users';
  453. }
  454. /**
  455. * Builds an error message based on the block params
  456. *
  457. * @return ErrorPageError
  458. */
  459. private function getBlockedEmailError() {
  460. $block = $this->getUser()->mBlock;
  461. $params = $block->getBlockErrorParams( $this->getContext() );
  462. $msg = $block->isSitewide() ? 'blockedtext' : 'blocked-email-user';
  463. return new ErrorPageError( 'blockedtitle', $msg, $params );
  464. }
  465. }