MediaWiki  master
EmailUser.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Mail;
22 
23 use BadMethodCallException;
24 use MailAddress;
39 use Message;
41 use RequestContext;
42 use RuntimeException;
43 use StatusValue;
44 use ThrottledError;
45 use UnexpectedValueException;
49 
56 class EmailUser {
60  public const CONSTRUCTOR_OPTIONS = [
66  ];
67 
69  private ServiceOptions $options;
71  private HookRunner $hookRunner;
73  private UserOptionsLookup $userOptionsLookup;
75  private CentralIdLookup $centralIdLookup;
77  private UserFactory $userFactory;
79  private IEmailer $emailer;
81  private IMessageFormatterFactory $messageFormatterFactory;
83  private ITextFormatter $contLangMsgFormatter;
84 
86  private Authority $sender;
87 
99  public function __construct(
100  ServiceOptions $options,
101  HookContainer $hookContainer,
102  UserOptionsLookup $userOptionsLookup,
103  CentralIdLookup $centralIdLookup,
104  UserFactory $userFactory,
105  IEmailer $emailer,
106  IMessageFormatterFactory $messageFormatterFactory,
107  ITextFormatter $contLangMsgFormatter,
108  Authority $sender
109  ) {
110  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
111  $this->options = $options;
112  $this->hookRunner = new HookRunner( $hookContainer );
113  $this->userOptionsLookup = $userOptionsLookup;
114  $this->centralIdLookup = $centralIdLookup;
115  $this->userFactory = $userFactory;
116  $this->emailer = $emailer;
117  $this->messageFormatterFactory = $messageFormatterFactory;
118  $this->contLangMsgFormatter = $contLangMsgFormatter;
119 
120  $this->sender = $sender;
121  }
122 
130  public function validateTarget( UserEmailContact $target ): StatusValue {
131  $targetIdentity = $target->getUser();
132 
133  if ( !$targetIdentity->getId() ) {
134  return StatusValue::newFatal( 'emailnotarget' );
135  }
136 
137  if ( !$target->isEmailConfirmed() ) {
138  return StatusValue::newFatal( 'noemailtext' );
139  }
140 
141  $targetUser = $this->userFactory->newFromUserIdentity( $targetIdentity );
142  if ( !$targetUser->canReceiveEmail() ) {
143  return StatusValue::newFatal( 'nowikiemailtext' );
144  }
145 
146  $senderUser = $this->userFactory->newFromAuthority( $this->sender );
147  if (
148  !$this->userOptionsLookup->getOption( $targetIdentity, 'email-allow-new-users' ) &&
149  $senderUser->isNewbie()
150  ) {
151  return StatusValue::newFatal( 'nowikiemailtext' );
152  }
153 
154  $muteList = $this->userOptionsLookup->getOption(
155  $targetIdentity,
156  'email-blacklist',
157  ''
158  );
159  if ( $muteList ) {
160  $muteList = MultiUsernameFilter::splitIds( $muteList );
161  $senderId = $this->centralIdLookup->centralIdFromLocalUser( $this->sender->getUser() );
162  if ( $senderId !== 0 && in_array( $senderId, $muteList ) ) {
163  return StatusValue::newFatal( 'nowikiemailtext' );
164  }
165  }
166 
167  return StatusValue::newGood();
168  }
169 
180  public function authorizeSend( string $editToken ): StatusValue {
181  if (
182  !$this->options->get( MainConfigNames::EnableEmail ) ||
183  !$this->options->get( MainConfigNames::EnableUserEmail )
184  ) {
185  return StatusValue::newFatal( 'usermaildisabled' );
186  }
187 
188  $user = $this->userFactory->newFromAuthority( $this->sender );
189 
190  // Run this before checking 'sendemail' permission
191  // to show appropriate message to anons (T160309)
192  if ( !$user->isEmailConfirmed() ) {
193  return StatusValue::newFatal( 'mailnologin' );
194  }
195 
196  // TODO We should simply use Authority for checking permissions and blocks (and the rate limit, after T310476)
197  // However, that requires a target page, and it's unclear what page should be used here (T339822).
198  if ( !$this->sender->isAllowed( 'sendemail' ) ) {
199  return StatusValue::newFatal( 'badaccess' );
200  }
201 
202  $block = $this->sender->getBlock();
203  if ( $block instanceof AbstractBlock && $block->appliesToRight( 'sendemail' ) ) {
204  return StatusValue::newFatal( $this->getBlockedMessage( $user ) );
205  }
206 
207  // Check the ping limiter without incrementing it - we'll check it
208  // again later and increment it on a successful send
209  if ( $user->pingLimiter( 'sendemail', 0 ) ) {
210  return StatusValue::newFatal( 'actionthrottledtext' );
211  }
212 
213  $hookErr = false;
214 
215  // TODO Remove deprecated hooks
216  $this->hookRunner->onUserCanSendEmail( $user, $hookErr );
217  $this->hookRunner->onEmailUserPermissionsErrors( $user, $editToken, $hookErr );
218  if ( is_array( $hookErr ) ) {
219  // SpamBlacklist uses null for the third element, and there might be more handlers not using an array.
220  $msgParamsArray = is_array( $hookErr[2] ) ? $hookErr[2] : [];
221  $ret = StatusValue::newFatal( $hookErr[1], ...$msgParamsArray );
222  $ret->value = $hookErr[0];
223  return $ret;
224  }
225  $hookStatus = StatusValue::newGood();
226  $hookRes = $this->hookRunner->onEmailUserAuthorizeSend( $this->sender, $hookStatus );
227  if ( !$hookRes && !$hookStatus->isGood() ) {
228  return $hookStatus;
229  }
230 
231  return StatusValue::newGood();
232  }
233 
244  public function sendEmailUnsafe(
245  UserEmailContact $target,
246  string $subject,
247  string $text,
248  bool $CCMe,
249  string $langCode
250  ): StatusValue {
251  $senderIdentity = $this->sender->getUser();
252  $targetStatus = $this->validateTarget( $target );
253  if ( !$targetStatus->isGood() ) {
254  return $targetStatus;
255  }
256 
257  $senderUser = $this->userFactory->newFromAuthority( $this->sender );
258  // Check and increment the rate limits
259  if ( $senderUser->pingLimiter( 'sendemail' ) ) {
260  throw $this->getThrottledError();
261  }
262 
263  $toAddress = MailAddress::newFromUser( $target );
264  $fromAddress = MailAddress::newFromUser( $senderUser );
265 
266  // Add a standard footer and trim up trailing newlines
267  $text = rtrim( $text ) . "\n\n-- \n";
268  $text .= $this->contLangMsgFormatter->format(
269  MessageValue::new( 'emailuserfooter', [ $fromAddress->name, $toAddress->name ] )
270  );
271 
272  if ( $this->options->get( MainConfigNames::EnableSpecialMute ) ) {
273  $text .= "\n" . $this->contLangMsgFormatter->format(
274  MessageValue::new(
275  'specialmute-email-footer',
276  [
277  $this->getSpecialMuteCanonicalURL( $senderIdentity->getName() ),
278  $senderIdentity->getName()
279  ]
280  )
281  );
282  }
283 
284  $error = false;
285  // TODO Remove deprecated ugly hook
286  if ( !$this->hookRunner->onEmailUser( $toAddress, $fromAddress, $subject, $text, $error ) ) {
287  if ( $error instanceof StatusValue ) {
288  return $error;
289  } elseif ( $error === false || $error === '' || $error === [] ) {
290  // Possibly to tell HTMLForm to pretend there was no submission?
291  return StatusValue::newFatal( 'hookaborted' );
292  } elseif ( $error === true ) {
293  // Hook sent the mail itself and indicates success?
294  return StatusValue::newGood();
295  } elseif ( is_array( $error ) ) {
296  $status = StatusValue::newGood();
297  foreach ( $error as $e ) {
298  $status->fatal( $e );
299  }
300  return $status;
301  } elseif ( $error instanceof MessageSpecifier ) {
302  return StatusValue::newFatal( $error );
303  } else {
304  // Setting $error to something else was deprecated in 1.29 and
305  // removed in 1.36, and so an exception is now thrown
306  $type = is_object( $error ) ? get_class( $error ) : gettype( $error );
307  throw new UnexpectedValueException(
308  'EmailUser hook set $error to unsupported type ' . $type
309  );
310  }
311  }
312 
313  $hookStatus = StatusValue::newGood();
314  $hookRes = $this->hookRunner->onEmailUserSendEmail(
315  $this->sender,
316  $fromAddress,
317  $target,
318  $toAddress,
319  $subject,
320  $text,
321  $hookStatus
322  );
323  if ( !$hookRes && !$hookStatus->isGood() ) {
324  return $hookStatus;
325  }
326 
327  [ $mailFrom, $replyTo ] = $this->getFromAndReplyTo( $fromAddress );
328 
329  $status = $this->emailer->send(
330  $toAddress,
331  $mailFrom,
332  $subject,
333  $text,
334  null,
335  [ 'replyTo' => $replyTo ]
336  );
337 
338  if ( !$status->isGood() ) {
339  return $status;
340  }
341 
342  // if the user requested a copy of this mail, do this now,
343  // unless they are emailing themselves, in which case one
344  // copy of the message is sufficient.
345  if ( $CCMe && !$toAddress->equals( $fromAddress ) ) {
346  $userMsgFormatter = $this->messageFormatterFactory->getTextFormatter( $langCode );
347  $ccTo = $fromAddress;
348  $ccFrom = $fromAddress;
349  $ccSubject = $userMsgFormatter->format(
350  MessageValue::new( 'emailccsubject' )->plaintextParams(
351  $target->getUser()->getName(),
352  $subject
353  )
354  );
355  $ccText = $text;
356 
357  $this->hookRunner->onEmailUserCC( $ccTo, $ccFrom, $ccSubject, $ccText );
358 
359  [ $mailFrom, $replyTo ] = $this->getFromAndReplyTo( $ccFrom );
360 
361  $ccStatus = $this->emailer->send(
362  $ccTo,
363  $mailFrom,
364  $ccSubject,
365  $ccText,
366  null,
367  [ 'replyTo' => $replyTo ]
368  );
369  $status->merge( $ccStatus );
370  }
371 
372  $this->hookRunner->onEmailUserComplete( $toAddress, $fromAddress, $subject, $text );
373 
374  return $status;
375  }
376 
382  private function getFromAndReplyTo( MailAddress $fromAddress ): array {
383  if ( $this->options->get( MainConfigNames::UserEmailUseReplyTo ) ) {
392  $mailFrom = new MailAddress(
393  $this->options->get( MainConfigNames::PasswordSender ),
394  $this->contLangMsgFormatter->format( MessageValue::new( 'emailsender' ) )
395  );
396  $replyTo = $fromAddress;
397  } else {
413  $mailFrom = $fromAddress;
414  $replyTo = null;
415  }
416  return [ $mailFrom, $replyTo ];
417  }
418 
425  private function getSpecialMuteCanonicalURL( string $targetName ): string {
426  if ( defined( 'MW_PHPUNIT_TEST' ) ) {
427  return "Ceci n'est pas une URL";
428  }
429  return SpecialPage::getTitleFor( 'Mute', $targetName )->getCanonicalURL();
430  }
431 
438  private function getThrottledError() {
439  if ( defined( 'MW_PHPUNIT_TEST' ) ) {
440  return new RuntimeException( "You are throttled, and I am not running heavy logic in the constructor" );
441  }
442  return new ThrottledError();
443  }
444 
452  private function getBlockedMessage( User $user ): Message {
453  if ( defined( 'MW_PHPUNIT_TEST' ) ) {
454  return new RawMessage( 'You shall not send' );
455  }
456  $blockErrorFormatter = MediaWikiServices::getInstance()->getBlockErrorFormatter();
457  $block = $user->getBlock();
458  if ( !$block ) {
459  throw new BadMethodCallException( 'This method should only be called if the user is blocked' );
460  }
461  return $blockErrorFormatter->getMessage(
462  $block,
463  $user,
464  RequestContext::getMain()->getLanguage(),
465  RequestContext::getMain()->getRequest()->getIP()
466  );
467  }
468 }
getUser()
if(!defined('MW_SETUP_CALLBACK'))
Definition: WebStart.php:88
Stores a single person's name and email address.
Definition: MailAddress.php:36
static newFromUser(UserEmailContact $user)
Create a new MailAddress object for the given user.
Definition: MailAddress.php:72
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:568
Variant of the Message class.
Definition: RawMessage.php:40
Command for sending emails to users.
Definition: EmailUser.php:56
sendEmailUnsafe(UserEmailContact $target, string $subject, string $text, bool $CCMe, string $langCode)
Really send a mail, without permission checks.
Definition: EmailUser.php:244
__construct(ServiceOptions $options, HookContainer $hookContainer, UserOptionsLookup $userOptionsLookup, CentralIdLookup $centralIdLookup, UserFactory $userFactory, IEmailer $emailer, IMessageFormatterFactory $messageFormatterFactory, ITextFormatter $contLangMsgFormatter, Authority $sender)
Definition: EmailUser.php:99
authorizeSend(string $editToken)
Authorize the email sending, checking permissions etc.
Definition: EmailUser.php:180
validateTarget(UserEmailContact $target)
Definition: EmailUser.php:130
A class containing constants representing the names of configuration variables.
const EnableUserEmail
Name constant for the EnableUserEmail setting, for use with Config::get()
const EnableSpecialMute
Name constant for the EnableSpecialMute setting, for use with Config::get()
const EnableEmail
Name constant for the EnableEmail setting, for use with Config::get()
const PasswordSender
Name constant for the PasswordSender setting, for use with Config::get()
const UserEmailUseReplyTo
Name constant for the UserEmailUseReplyTo setting, for use with Config::get()
Service locator for MediaWiki core services.
static splitIds( $str)
Splits a newline separated list of user ids into an array.
Parent class for all special pages.
Definition: SpecialPage.php:66
The CentralIdLookup service allows for connecting local users with cluster-wide IDs.
Creates User objects.
Definition: UserFactory.php:41
Provides access to user options.
internal since 1.36
Definition: User.php:98
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition: Message.php:144
Group all the pieces relevant to the context of a request into one instance.
static getMain()
Get the RequestContext object associated with the main request.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: StatusValue.php:46
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
Show an error when the user hits a rate limit.
Value object representing a message for i18n.
Interface for sending emails.
Definition: IEmailer.php:32
getUser()
Get the identity of the user this contact belongs to.
isEmailConfirmed()
Whether user email was confirmed.
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
A simple factory providing a message formatter for a given language code.