diff --git a/src/applications/auth/controller/PhabricatorEmailLoginController.php b/src/applications/auth/controller/PhabricatorEmailLoginController.php index 8b5a6b19c5..92067fc3a3 100644 --- a/src/applications/auth/controller/PhabricatorEmailLoginController.php +++ b/src/applications/auth/controller/PhabricatorEmailLoginController.php @@ -1,175 +1,172 @@ getRequest(); if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) { return new Aphront400Response(); } $e_email = true; $e_captcha = true; $errors = array(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); if ($request->isFormPost()) { $e_email = null; $e_captcha = pht('Again'); $captcha_ok = AphrontFormRecaptchaControl::processCaptcha($request); if (!$captcha_ok) { $errors[] = pht('Captcha response is incorrect, try again.'); $e_captcha = pht('Invalid'); } $email = $request->getStr('email'); if (!strlen($email)) { $errors[] = pht('You must provide an email address.'); $e_email = pht('Required'); } if (!$errors) { // NOTE: Don't validate the email unless the captcha is good; this makes // it expensive to fish for valid email addresses while giving the user // a better error if they goof their email. $target_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $email); $target_user = null; if ($target_email) { $target_user = id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $target_email->getUserPHID()); } if (!$target_user) { $errors[] = pht('There is no account associated with that email address.'); $e_email = pht('Invalid'); } // If this address is unverified, only send a reset link to it if // the account has no verified addresses. This prevents an opportunistic // attacker from compromising an account if a user adds an email // address but mistypes it and doesn't notice. // (For a newly created account, all the addresses may be unverified, // which is why we'll send to an unverified address in that case.) if ($target_email && !$target_email->getIsVerified()) { $verified_addresses = id(new PhabricatorUserEmail())->loadAllWhere( 'userPHID = %s AND isVerified = 1', $target_email->getUserPHID()); if ($verified_addresses) { $errors[] = pht( 'That email addess is not verified. You can only send '. 'password reset links to a verified address.'); $e_email = pht('Unverified'); } } if (!$errors) { $engine = new PhabricatorAuthSessionEngine(); $uri = $engine->getOneTimeLoginURI( $target_user, null, PhabricatorAuthSessionEngine::ONETIME_RESET); if ($is_serious) { $body = <<setSubject(pht('[Phabricator] Password Reset')) + ->setForceDelivery(true) ->addRawTos(array($target_email->getAddress())) ->setBody($body) ->saveAndSend(); return $this->newDialog() ->setTitle(pht('Check Your Email')) ->setShortTitle(pht('Email Sent')) ->appendParagraph( pht('An email has been sent with a link you can use to login.')) ->addCancelButton('/', pht('Done')); } } } $error_view = null; if ($errors) { $error_view = new AphrontErrorView(); $error_view->setErrors($errors); } $email_auth = new PHUIFormLayoutView(); $email_auth->appendChild($error_view); $email_auth ->setUser($request->getUser()) ->setFullWidth(true) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Email')) ->setName('email') ->setValue($request->getStr('email')) ->setError($e_email)) ->appendChild( id(new AphrontFormRecaptchaControl()) ->setLabel(pht('Captcha')) ->setError($e_captcha)); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Reset Password')); $dialog = new AphrontDialogView(); $dialog->setUser($request->getUser()); $dialog->setTitle(pht( 'Forgot Password / Email Login')); $dialog->appendChild($email_auth); $dialog->addSubmitButton(pht('Send Email')); $dialog->setSubmitURI('/login/email/'); return $this->buildApplicationPage( array( $crumbs, $dialog, ), array( 'title' => pht('Forgot Password'), )); } } diff --git a/src/applications/legalpad/controller/LegalpadDocumentSignController.php b/src/applications/legalpad/controller/LegalpadDocumentSignController.php index cfd5e19d93..ca0a8c5801 100644 --- a/src/applications/legalpad/controller/LegalpadDocumentSignController.php +++ b/src/applications/legalpad/controller/LegalpadDocumentSignController.php @@ -1,646 +1,647 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $viewer = $request->getUser(); $document = id(new LegalpadDocumentQuery()) ->setViewer($viewer) ->withIDs(array($this->id)) ->needDocumentBodies(true) ->executeOne(); if (!$document) { return new Aphront404Response(); } list($signer_phid, $signature_data) = $this->readSignerInformation( $document, $request); $signature = null; $type_individual = LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL; $is_individual = ($document->getSignatureType() == $type_individual); if ($is_individual) { if ($signer_phid) { // TODO: This is odd and should probably be adjusted after grey/external // accounts work better, but use the omnipotent viewer to check for a // signature so we can pick up anonymous/grey signatures. $signature = id(new LegalpadDocumentSignatureQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withDocumentPHIDs(array($document->getPHID())) ->withSignerPHIDs(array($signer_phid)) ->executeOne(); if ($signature && !$viewer->isLoggedIn()) { return $this->newDialog() ->setTitle(pht('Already Signed')) ->appendParagraph(pht('You have already signed this document!')) ->addCancelButton('/'.$document->getMonogram(), pht('Okay')); } } $signed_status = null; if (!$signature) { $has_signed = false; $signature = id(new LegalpadDocumentSignature()) ->setSignerPHID($signer_phid) ->setDocumentPHID($document->getPHID()) ->setDocumentVersion($document->getVersions()); // If the user is logged in, show a notice that they haven't signed. // If they aren't logged in, we can't be as sure, so don't show // anything. if ($viewer->isLoggedIn()) { $signed_status = id(new AphrontErrorView()) ->setSeverity(AphrontErrorView::SEVERITY_WARNING) ->setErrors( array( pht('You have not signed this document yet.'), )); } } else { $has_signed = true; $signature_data = $signature->getSignatureData(); // In this case, we know they've signed. $signed_at = $signature->getDateCreated(); if ($signature->getIsExemption()) { $exemption_phid = $signature->getExemptionPHID(); $handles = $this->loadViewerHandles(array($exemption_phid)); $exemption_handle = $handles[$exemption_phid]; $signed_text = pht( 'You do not need to sign this document. '. '%s added a signature exemption for you on %s.', $exemption_handle->renderLink(), phabricator_datetime($signed_at, $viewer)); } else { $signed_text = pht( 'You signed this document on %s.', phabricator_datetime($signed_at, $viewer)); } $signed_status = id(new AphrontErrorView()) ->setSeverity(AphrontErrorView::SEVERITY_NOTICE) ->setErrors(array($signed_text)); } $field_errors = array( 'name' => true, 'email' => true, 'agree' => true, ); } else { $signature = id(new LegalpadDocumentSignature()) ->setDocumentPHID($document->getPHID()) ->setDocumentVersion($document->getVersions()); if ($viewer->isLoggedIn()) { $has_signed = false; $signed_status = null; } else { // This just hides the form. $has_signed = true; $login_text = pht( 'This document requires a corporate signatory. You must log in to '. 'accept this document on behalf of a company you represent.'); $signed_status = id(new AphrontErrorView()) ->setSeverity(AphrontErrorView::SEVERITY_WARNING) ->setErrors(array($login_text)); } $field_errors = array( 'name' => true, 'address' => true, 'contact.name' => true, 'email' => true, ); } $signature->setSignatureData($signature_data); $errors = array(); if ($request->isFormOrHisecPost() && !$has_signed) { // Require two-factor auth to sign legal documents. if ($viewer->isLoggedIn()) { $engine = new PhabricatorAuthSessionEngine(); $engine->requireHighSecuritySession( $viewer, $request, '/'.$document->getMonogram()); } list($form_data, $errors, $field_errors) = $this->readSignatureForm( $document, $request); $signature_data = $form_data + $signature_data; $signature->setSignatureData($signature_data); $signature->setSignatureType($document->getSignatureType()); $signature->setSignerName((string)idx($signature_data, 'name')); $signature->setSignerEmail((string)idx($signature_data, 'email')); $agree = $request->getExists('agree'); if (!$agree) { $errors[] = pht( 'You must check "I agree to the terms laid forth above."'); $field_errors['agree'] = pht('Required'); } if ($viewer->isLoggedIn() && $is_individual) { $verified = LegalpadDocumentSignature::VERIFIED; } else { $verified = LegalpadDocumentSignature::UNVERIFIED; } $signature->setVerified($verified); if (!$errors) { $signature->save(); // If the viewer is logged in, send them to the document page, which // will show that they have signed the document. Otherwise, send them // to a completion page. if ($viewer->isLoggedIn() && $is_individual) { $next_uri = '/'.$document->getMonogram(); } else { $this->sendVerifySignatureEmail( $document, $signature); $next_uri = $this->getApplicationURI('done/'); } return id(new AphrontRedirectResponse())->setURI($next_uri); } } $document_body = $document->getDocumentBody(); $engine = id(new PhabricatorMarkupEngine()) ->setViewer($viewer); $engine->addObject( $document_body, LegalpadDocumentBody::MARKUP_FIELD_TEXT); $engine->process(); $document_markup = $engine->getOutput( $document_body, LegalpadDocumentBody::MARKUP_FIELD_TEXT); $title = $document_body->getTitle(); $manage_uri = $this->getApplicationURI('view/'.$document->getID().'/'); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $document, PhabricatorPolicyCapability::CAN_EDIT); $header = id(new PHUIHeaderView()) ->setHeader($title) ->addActionLink( id(new PHUIButtonView()) ->setTag('a') ->setIcon( id(new PHUIIconView()) ->setIconFont('fa-pencil')) ->setText(pht('Manage Document')) ->setHref($manage_uri) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); $preamble = null; if (strlen($document->getPreamble())) { $preamble_text = PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff())->setContent( $document->getPreamble()), 'default', $viewer); $preamble = id(new PHUIPropertyListView()) ->addSectionHeader(pht('Preamble')) ->addTextContent($preamble_text); } $content = id(new PHUIDocumentView()) ->addClass('legalpad') ->setHeader($header) ->setFontKit(PHUIDocumentView::FONT_SOURCE_SANS) ->appendChild( array( $signed_status, $preamble, $document_markup, )); if (!$has_signed) { $error_view = null; if ($errors) { $error_view = id(new AphrontErrorView()) ->setErrors($errors); } $signature_form = $this->buildSignatureForm( $document, $signature, $field_errors); $subheader = id(new PHUIHeaderView()) ->setHeader(pht('Agree and Sign Document')) ->setBleedHeader(true); $content->appendChild( array( $subheader, $error_view, $signature_form, )); } $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($document->getMonogram()); return $this->buildApplicationPage( array( $crumbs, $content, ), array( 'title' => $title, 'pageObjects' => array($document->getPHID()), )); } private function readSignerInformation( LegalpadDocument $document, AphrontRequest $request) { $viewer = $request->getUser(); $signer_phid = null; $signature_data = array(); switch ($document->getSignatureType()) { case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL: if ($viewer->isLoggedIn()) { $signer_phid = $viewer->getPHID(); $signature_data = array( 'name' => $viewer->getRealName(), 'email' => $viewer->loadPrimaryEmailAddress(), ); } else if ($request->isFormPost()) { $email = new PhutilEmailAddress($request->getStr('email')); if (strlen($email->getDomainName())) { $email_obj = id(new PhabricatorUserEmail()) ->loadOneWhere('address = %s', $email->getAddress()); if ($email_obj) { return $this->signInResponse(); } $external_account = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withAccountTypes(array('email')) ->withAccountDomains(array($email->getDomainName())) ->withAccountIDs(array($email->getAddress())) ->loadOneOrCreate(); if ($external_account->getUserPHID()) { return $this->signInResponse(); } $signer_phid = $external_account->getPHID(); } } break; case LegalpadDocument::SIGNATURE_TYPE_CORPORATION: $signer_phid = $viewer->getPHID(); if ($signer_phid) { $signature_data = array( 'contact.name' => $viewer->getRealName(), 'email' => $viewer->loadPrimaryEmailAddress(), 'actorPHID' => $viewer->getPHID(), ); } break; } return array($signer_phid, $signature_data); } private function buildSignatureForm( LegalpadDocument $document, LegalpadDocumentSignature $signature, array $errors) { $viewer = $this->getRequest()->getUser(); $data = $signature->getSignatureData(); $form = id(new AphrontFormView()) ->setUser($viewer); $signature_type = $document->getSignatureType(); switch ($signature_type) { case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL: $this->buildIndividualSignatureForm( $form, $document, $signature, $errors); break; case LegalpadDocument::SIGNATURE_TYPE_CORPORATION: $this->buildCorporateSignatureForm( $form, $document, $signature, $errors); break; default: throw new Exception( pht( 'This document has an unknown signature type ("%s").', $signature_type)); } $form ->appendChild( id(new AphrontFormCheckboxControl()) ->setError(idx($errors, 'agree', null)) ->addCheckbox( 'agree', 'agree', pht('I agree to the terms laid forth above.'), false)) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Sign Document')) ->addCancelButton($this->getApplicationURI())); return $form; } private function buildIndividualSignatureForm( AphrontFormView $form, LegalpadDocument $document, LegalpadDocumentSignature $signature, array $errors) { $data = $signature->getSignatureData(); $form ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Name')) ->setValue(idx($data, 'name', '')) ->setName('name') ->setError(idx($errors, 'name', null))); $viewer = $this->getRequest()->getUser(); if (!$viewer->isLoggedIn()) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Email')) ->setValue(idx($data, 'email', '')) ->setName('email') ->setError(idx($errors, 'email', null))); } return $form; } private function buildCorporateSignatureForm( AphrontFormView $form, LegalpadDocument $document, LegalpadDocumentSignature $signature, array $errors) { $data = $signature->getSignatureData(); $form ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Company Name')) ->setValue(idx($data, 'name', '')) ->setName('name') ->setError(idx($errors, 'name', null))) ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel(pht('Company Address')) ->setValue(idx($data, 'address', '')) ->setName('address') ->setError(idx($errors, 'address', null))) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Contact Name')) ->setValue(idx($data, 'contact.name', '')) ->setName('contact.name') ->setError(idx($errors, 'contact.name', null))) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Contact Email')) ->setValue(idx($data, 'email', '')) ->setName('email') ->setError(idx($errors, 'email', null))); return $form; } private function readSignatureForm( LegalpadDocument $document, AphrontRequest $request) { $signature_type = $document->getSignatureType(); switch ($signature_type) { case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL: $result = $this->readIndividualSignatureForm( $document, $request); break; case LegalpadDocument::SIGNATURE_TYPE_CORPORATION: $result = $this->readCorporateSignatureForm( $document, $request); break; default: throw new Exception( pht( 'This document has an unknown signature type ("%s").', $signature_type)); } return $result; } private function readIndividualSignatureForm( LegalpadDocument $document, AphrontRequest $request) { $signature_data = array(); $errors = array(); $field_errors = array(); $name = $request->getStr('name'); if (!strlen($name)) { $field_errors['name'] = pht('Required'); $errors[] = pht('Name field is required.'); } else { $field_errors['name'] = null; } $signature_data['name'] = $name; $viewer = $request->getUser(); if ($viewer->isLoggedIn()) { $email = $viewer->loadPrimaryEmailAddress(); } else { $email = $request->getStr('email'); $addr_obj = null; if (!strlen($email)) { $field_errors['email'] = pht('Required'); $errors[] = pht('Email field is required.'); } else { $addr_obj = new PhutilEmailAddress($email); $domain = $addr_obj->getDomainName(); if (!$domain) { $field_errors['email'] = pht('Invalid'); $errors[] = pht('A valid email is required.'); } else { $field_errors['email'] = null; } } } $signature_data['email'] = $email; return array($signature_data, $errors, $field_errors); } private function readCorporateSignatureForm( LegalpadDocument $document, AphrontRequest $request) { $viewer = $request->getUser(); if (!$viewer->isLoggedIn()) { throw new Exception( pht( 'You can not sign a document on behalf of a corporation unless '. 'you are logged in.')); } $signature_data = array(); $errors = array(); $field_errors = array(); $name = $request->getStr('name'); if (!strlen($name)) { $field_errors['name'] = pht('Required'); $errors[] = pht('Company name is required.'); } else { $field_errors['name'] = null; } $signature_data['name'] = $name; $address = $request->getStr('address'); if (!strlen($address)) { $field_errors['address'] = pht('Required'); $errors[] = pht('Company address is required.'); } else { $field_errors['address'] = null; } $signature_data['address'] = $address; $contact_name = $request->getStr('contact.name'); if (!strlen($contact_name)) { $field_errors['contact.name'] = pht('Required'); $errors[] = pht('Contact name is required.'); } else { $field_errors['contact.name'] = null; } $signature_data['contact.name'] = $contact_name; $email = $request->getStr('email'); $addr_obj = null; if (!strlen($email)) { $field_errors['email'] = pht('Required'); $errors[] = pht('Contact email is required.'); } else { $addr_obj = new PhutilEmailAddress($email); $domain = $addr_obj->getDomainName(); if (!$domain) { $field_errors['email'] = pht('Invalid'); $errors[] = pht('A valid email is required.'); } else { $field_errors['email'] = null; } } $signature_data['email'] = $email; return array($signature_data, $errors, $field_errors); } private function sendVerifySignatureEmail( LegalpadDocument $doc, LegalpadDocumentSignature $signature) { $signature_data = $signature->getSignatureData(); $email = new PhutilEmailAddress($signature_data['email']); $doc_name = $doc->getTitle(); $doc_link = PhabricatorEnv::getProductionURI('/'.$doc->getMonogram()); $path = $this->getApplicationURI(sprintf( '/verify/%s/', $signature->getSecretKey())); $link = PhabricatorEnv::getProductionURI($path); $name = idx($signature_data, 'name'); $body = <<addRawTos(array($email->getAddress())) ->setSubject(pht('[Legalpad] Signature Verification')) + ->setForceDelivery(true) ->setBody($body) ->setRelatedPHID($signature->getDocumentPHID()) ->saveAndSend(); } private function signInResponse() { return id(new Aphront403Response()) ->setForbiddenText(pht( 'The email address specified is associated with an account. '. 'Please login to that account and sign this document again.')); } } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php index 75ee634b86..cb6bac7262 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php @@ -1,889 +1,925 @@ status = self::STATUS_QUEUE; $this->parameters = array(); parent::__construct(); } public function getConfiguration() { return array( self::CONFIG_SERIALIZATION => array( 'parameters' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } protected function setParam($param, $value) { $this->parameters[$param] = $value; return $this; } protected function getParam($param, $default = null) { return idx($this->parameters, $param, $default); } /** * Set tags (@{class:MetaMTANotificationType} constants) which identify the * content of this mail in a general way. These tags are used to allow users * to opt out of receiving certain types of mail, like updates when a task's * projects change. * * @param list List of @{class:MetaMTANotificationType} constants. * @return this */ public function setMailTags(array $tags) { $this->setParam('mailtags', array_unique($tags)); return $this; } public function getMailTags() { return $this->getParam('mailtags', array()); } /** * In Gmail, conversations will be broken if you reply to a thread and the * server sends back a response without referencing your Message-ID, even if * it references a Message-ID earlier in the thread. To avoid this, use the * parent email's message ID explicitly if it's available. This overwrites the * "In-Reply-To" and "References" headers we would otherwise generate. This * needs to be set whenever an action is triggered by an email message. See * T251 for more details. * * @param string The "Message-ID" of the email which precedes this one. * @return this */ public function setParentMessageID($id) { $this->setParam('parent-message-id', $id); return $this; } public function getParentMessageID() { return $this->getParam('parent-message-id'); } public function getSubject() { return $this->getParam('subject'); } public function addTos(array $phids) { $phids = array_unique($phids); $this->setParam('to', $phids); return $this; } public function addRawTos(array $raw_email) { // Strip addresses down to bare emails, since the MailAdapter API currently // requires we pass it just the address (like `alincoln@logcabin.org`), not // a full string like `"Abraham Lincoln" `. foreach ($raw_email as $key => $email) { $object = new PhutilEmailAddress($email); $raw_email[$key] = $object->getAddress(); } $this->setParam('raw-to', $raw_email); return $this; } public function addCCs(array $phids) { $phids = array_unique($phids); $this->setParam('cc', $phids); return $this; } public function setExcludeMailRecipientPHIDs(array $exclude) { $this->setParam('exclude', $exclude); return $this; } private function getExcludeMailRecipientPHIDs() { return $this->getParam('exclude', array()); } public function getTranslation(array $objects) { $default_translation = PhabricatorEnv::getEnvConfig('translation.provider'); $return = null; $recipients = array_merge( idx($this->parameters, 'to', array()), idx($this->parameters, 'cc', array())); foreach (array_select_keys($objects, $recipients) as $object) { $translation = null; if ($object instanceof PhabricatorUser) { $translation = $object->getTranslation(); } if (!$translation) { $translation = $default_translation; } if ($return && $translation != $return) { return $default_translation; } $return = $translation; } if (!$return) { $return = $default_translation; } return $return; } public function addPHIDHeaders($name, array $phids) { foreach ($phids as $phid) { $this->addHeader($name, '<'.$phid.'>'); } return $this; } public function addHeader($name, $value) { $this->parameters['headers'][] = array($name, $value); return $this; } public function addAttachment(PhabricatorMetaMTAAttachment $attachment) { $this->parameters['attachments'][] = $attachment->toDictionary(); return $this; } public function getAttachments() { $dicts = $this->getParam('attachments'); $result = array(); foreach ($dicts as $dict) { $result[] = PhabricatorMetaMTAAttachment::newFromDictionary($dict); } return $result; } public function setAttachments(array $attachments) { assert_instances_of($attachments, 'PhabricatorMetaMTAAttachment'); $this->setParam('attachments', mpull($attachments, 'toDictionary')); return $this; } public function setFrom($from) { $this->setParam('from', $from); return $this; } public function setReplyTo($reply_to) { $this->setParam('reply-to', $reply_to); return $this; } public function setSubject($subject) { $this->setParam('subject', $subject); return $this; } public function setSubjectPrefix($prefix) { $this->setParam('subject-prefix', $prefix); return $this; } public function setVarySubjectPrefix($prefix) { $this->setParam('vary-subject-prefix', $prefix); return $this; } public function setBody($body) { $this->setParam('body', $body); return $this; } public function getBody() { return $this->getParam('body'); } public function setIsHTML($html) { $this->setParam('is-html', $html); return $this; } public function setIsErrorEmail($is_error) { $this->setParam('is-error', $is_error); return $this; } public function getIsErrorEmail() { return $this->getParam('is-error', false); } public function getToPHIDs() { return $this->getParam('to', array()); } public function getRawToAddresses() { return $this->getParam('raw-to', array()); } public function getCcPHIDs() { return $this->getParam('cc', array()); } + /** + * Force delivery of a message, even if recipients have preferences which + * would otherwise drop the message. + * + * This is primarily intended to let users who don't want any email still + * receive things like password resets. + * + * @param bool True to force delivery despite user preferences. + * @return this + */ + public function setForceDelivery($force) { + $this->setParam('force', $force); + return $this; + } + + public function getForceDelivery() { + return $this->getParam('force', false); + } + /** * Flag that this is an auto-generated bulk message and should have bulk * headers added to it if appropriate. Broadly, this means some flavor of * "Precedence: bulk" or similar, but is implementation and configuration * dependent. * * @param bool True if the mail is automated bulk mail. * @return this */ public function setIsBulk($is_bulk) { $this->setParam('is-bulk', $is_bulk); return $this; } /** * Use this method to set an ID used for message threading. MetaMTA will * set appropriate headers (Message-ID, In-Reply-To, References and * Thread-Index) based on the capabilities of the underlying mailer. * * @param string Unique identifier, appropriate for use in a Message-ID, * In-Reply-To or References headers. * @param bool If true, indicates this is the first message in the thread. * @return this */ public function setThreadID($thread_id, $is_first_message = false) { $this->setParam('thread-id', $thread_id); $this->setParam('is-first-message', $is_first_message); return $this; } /** * Save a newly created mail to the database. The mail will eventually be * delivered by the MetaMTA daemon. * * @return this */ public function saveAndSend() { return $this->save(); } public function save() { if ($this->getID()) { return parent::save(); } // NOTE: When mail is sent from CLI scripts that run tasks in-process, we // may re-enter this method from within scheduleTask(). The implementation // is intended to avoid anything awkward if we end up reentering this // method. $this->openTransaction(); // Save to generate a task ID. $result = parent::save(); // Queue a task to send this mail. $mailer_task = PhabricatorWorker::scheduleTask( 'PhabricatorMetaMTAWorker', $this->getID(), PhabricatorWorker::PRIORITY_ALERTS); $this->saveTransaction(); return $result; } public function buildDefaultMailer() { return PhabricatorEnv::newObjectFromConfig('metamta.mail-adapter'); } /** * Attempt to deliver an email immediately, in this process. * * @param bool Try to deliver this email even if it has already been * delivered or is in backoff after a failed delivery attempt. * @param PhabricatorMailImplementationAdapter Use a specific mail adapter, * instead of the default. * * @return void */ public function sendNow( $force_send = false, PhabricatorMailImplementationAdapter $mailer = null) { if ($mailer === null) { $mailer = $this->buildDefaultMailer(); } if (!$force_send) { if ($this->getStatus() != self::STATUS_QUEUE) { throw new Exception('Trying to send an already-sent mail!'); } } try { $params = $this->parameters; $actors = $this->loadAllActors(); $deliverable_actors = $this->filterDeliverableActors($actors); $default_from = PhabricatorEnv::getEnvConfig('metamta.default-address'); if (empty($params['from'])) { $mailer->setFrom($default_from); } $is_first = idx($params, 'is-first-message'); unset($params['is-first-message']); $is_threaded = (bool)idx($params, 'thread-id'); $reply_to_name = idx($params, 'reply-to-name', ''); unset($params['reply-to-name']); $add_cc = array(); $add_to = array(); foreach ($params as $key => $value) { switch ($key) { case 'from': $from = $value; $actor_email = null; $actor_name = null; $actor = idx($actors, $from); if ($actor) { $actor_email = $actor->getEmailAddress(); $actor_name = $actor->getName(); } $can_send_as_user = $actor_email && PhabricatorEnv::getEnvConfig('metamta.can-send-as-user'); if ($can_send_as_user) { $mailer->setFrom($actor_email, $actor_name); } else { $from_email = coalesce($actor_email, $default_from); $from_name = coalesce($actor_name, pht('Phabricator')); if (empty($params['reply-to'])) { $params['reply-to'] = $from_email; $params['reply-to-name'] = $from_name; } $mailer->setFrom($default_from, $from_name); } break; case 'reply-to': $mailer->addReplyTo($value, $reply_to_name); break; case 'to': $to_phids = $this->expandRecipients($value); $to_actors = array_select_keys($deliverable_actors, $to_phids); $add_to = array_merge( $add_to, mpull($to_actors, 'getEmailAddress')); break; case 'raw-to': $add_to = array_merge($add_to, $value); break; case 'cc': $cc_phids = $this->expandRecipients($value); $cc_actors = array_select_keys($deliverable_actors, $cc_phids); $add_cc = array_merge( $add_cc, mpull($cc_actors, 'getEmailAddress')); break; case 'headers': foreach ($value as $pair) { list($header_key, $header_value) = $pair; // NOTE: If we have \n in a header, SES rejects the email. $header_value = str_replace("\n", ' ', $header_value); $mailer->addHeader($header_key, $header_value); } break; case 'attachments': $value = $this->getAttachments(); foreach ($value as $attachment) { $mailer->addAttachment( $attachment->getData(), $attachment->getFilename(), $attachment->getMimeType()); } break; case 'body': $max = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); if (strlen($value) > $max) { $value = phutil_utf8_shorten($value, $max); $value .= "\n"; $value .= pht('(This email was truncated at %d bytes.)', $max); } $mailer->setBody($value); break; case 'subject': // Only try to use preferences if everything is multiplexed, so we // get consistent behavior. $use_prefs = self::shouldMultiplexAllMail(); $prefs = null; if ($use_prefs) { // If multiplexing is enabled, some recipients will be in "Cc" // rather than "To". We'll move them to "To" later (or supply a // dummy "To") but need to look for the recipient in either the // "To" or "Cc" fields here. $target_phid = head(idx($params, 'to', array())); if (!$target_phid) { $target_phid = head(idx($params, 'cc', array())); } if ($target_phid) { $user = id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $target_phid); if ($user) { $prefs = $user->loadPreferences(); } } } $subject = array(); if ($is_threaded) { $add_re = PhabricatorEnv::getEnvConfig('metamta.re-prefix'); if ($prefs) { $add_re = $prefs->getPreference( PhabricatorUserPreferences::PREFERENCE_RE_PREFIX, $add_re); } if ($add_re) { $subject[] = 'Re:'; } } $subject[] = trim(idx($params, 'subject-prefix')); $vary_prefix = idx($params, 'vary-subject-prefix'); if ($vary_prefix != '') { $use_subject = PhabricatorEnv::getEnvConfig( 'metamta.vary-subjects'); if ($prefs) { $use_subject = $prefs->getPreference( PhabricatorUserPreferences::PREFERENCE_VARY_SUBJECT, $use_subject); } if ($use_subject) { $subject[] = $vary_prefix; } } $subject[] = $value; $mailer->setSubject(implode(' ', array_filter($subject))); break; case 'is-html': if ($value) { $mailer->setIsHTML(true); } break; case 'is-bulk': if ($value) { if (PhabricatorEnv::getEnvConfig('metamta.precedence-bulk')) { $mailer->addHeader('Precedence', 'bulk'); } } break; case 'thread-id': // NOTE: Gmail freaks out about In-Reply-To and References which // aren't in the form ""; this is also required // by RFC 2822, although some clients are more liberal in what they // accept. $domain = PhabricatorEnv::getEnvConfig('metamta.domain'); $value = '<'.$value.'@'.$domain.'>'; if ($is_first && $mailer->supportsMessageIDHeader()) { $mailer->addHeader('Message-ID', $value); } else { $in_reply_to = $value; $references = array($value); $parent_id = $this->getParentMessageID(); if ($parent_id) { $in_reply_to = $parent_id; // By RFC 2822, the most immediate parent should appear last // in the "References" header, so this order is intentional. $references[] = $parent_id; } $references = implode(' ', $references); $mailer->addHeader('In-Reply-To', $in_reply_to); $mailer->addHeader('References', $references); } $thread_index = $this->generateThreadIndex($value, $is_first); $mailer->addHeader('Thread-Index', $thread_index); break; case 'mailtags': // Handled below. break; case 'subject-prefix': case 'vary-subject-prefix': // Handled above. break; default: // Just discard. } } if (!$add_to && !$add_cc) { $this->setStatus(self::STATUS_VOID); $this->setMessage( 'Message has no valid recipients: all To/Cc are disabled, invalid, '. 'or configured not to receive this mail.'); return $this->save(); } if ($this->getIsErrorEmail()) { $all_recipients = array_merge($add_to, $add_cc); if ($this->shouldRateLimitMail($all_recipients)) { $this->setStatus(self::STATUS_VOID); $this->setMessage( pht( 'This is an error email, but one or more recipients have '. 'exceeded the error email rate limit. Declining to deliver '. 'message.')); return $this->save(); } } $mailer->addHeader('X-Phabricator-Sent-This-Message', 'Yes'); $mailer->addHeader('X-Mail-Transport-Agent', 'MetaMTA'); // Some clients respect this to suppress OOF and other auto-responses. $mailer->addHeader('X-Auto-Response-Suppress', 'All'); // If the message has mailtags, filter out any recipients who don't want // to receive this type of mail. $mailtags = $this->getParam('mailtags'); if ($mailtags) { $tag_header = array(); foreach ($mailtags as $mailtag) { $tag_header[] = '<'.$mailtag.'>'; } $tag_header = implode(', ', $tag_header); $mailer->addHeader('X-Phabricator-Mail-Tags', $tag_header); } // Some mailers require a valid "To:" in order to deliver mail. If we // don't have any "To:", try to fill it in with a placeholder "To:". // If that also fails, move the "Cc:" line to "To:". if (!$add_to) { $placeholder_key = 'metamta.placeholder-to-recipient'; $placeholder = PhabricatorEnv::getEnvConfig($placeholder_key); if ($placeholder !== null) { $add_to = array($placeholder); } else { $add_to = $add_cc; $add_cc = array(); } } $add_to = array_unique($add_to); $add_cc = array_diff(array_unique($add_cc), $add_to); $mailer->addTos($add_to); if ($add_cc) { $mailer->addCCs($add_cc); } } catch (Exception $ex) { $this ->setStatus(self::STATUS_FAIL) ->setMessage($ex->getMessage()) ->save(); throw $ex; } try { $ok = $mailer->send(); if (!$ok) { // TODO: At some point, we should clean this up and make all mailers // throw. throw new Exception( pht('Mail adapter encountered an unexpected, unspecified failure.')); } $this->setStatus(self::STATUS_SENT); $this->save(); return $this; } catch (PhabricatorMetaMTAPermanentFailureException $ex) { $this ->setStatus(self::STATUS_FAIL) ->setMessage($ex->getMessage()) ->save(); throw $ex; } catch (Exception $ex) { $this ->setMessage($ex->getMessage()."\n".$ex->getTraceAsString()) ->save(); throw $ex; } } public static function getReadableStatus($status_code) { static $readable = array( self::STATUS_QUEUE => 'Queued for Delivery', self::STATUS_FAIL => 'Delivery Failed', self::STATUS_SENT => 'Sent', self::STATUS_VOID => 'Void', ); $status_code = coalesce($status_code, '?'); return idx($readable, $status_code, $status_code); } private function generateThreadIndex($seed, $is_first_mail) { // When threading, Outlook ignores the 'References' and 'In-Reply-To' // headers that most clients use. Instead, it uses a custom 'Thread-Index' // header. The format of this header is something like this (from // camel-exchange-folder.c in Evolution Exchange): /* A new post to a folder gets a 27-byte-long thread index. (The value * is apparently unique but meaningless.) Each reply to a post gets a * 32-byte-long thread index whose first 27 bytes are the same as the * parent's thread index. Each reply to any of those gets a * 37-byte-long thread index, etc. The Thread-Index header contains a * base64 representation of this value. */ // The specific implementation uses a 27-byte header for the first email // a recipient receives, and a random 5-byte suffix (32 bytes total) // thereafter. This means that all the replies are (incorrectly) siblings, // but it would be very difficult to keep track of the entire tree and this // gets us reasonable client behavior. $base = substr(md5($seed), 0, 27); if (!$is_first_mail) { // Not totally sure, but it seems like outlook orders replies by // thread-index rather than timestamp, so to get these to show up in the // right order we use the time as the last 4 bytes. $base .= ' '.pack('N', time()); } return base64_encode($base); } public static function shouldMultiplexAllMail() { return PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient'); } /* -( Managing Recipients )------------------------------------------------ */ /** * Get all of the recipients for this mail, after preference filters are * applied. This list has all objects to whom delivery will be attempted. * * @return list A list of all recipients to whom delivery will be * attempted. * @task recipients */ public function buildRecipientList() { $actors = $this->loadActors( array_merge( $this->getToPHIDs(), $this->getCcPHIDs())); $actors = $this->filterDeliverableActors($actors); return mpull($actors, 'getPHID'); } public function loadAllActors() { $actor_phids = array_merge( array($this->getParam('from')), $this->getToPHIDs(), $this->getCcPHIDs()); $this->loadRecipientExpansions($actor_phids); $actor_phids = $this->expandRecipients($actor_phids); return $this->loadActors($actor_phids); } private function loadRecipientExpansions(array $phids) { $expansions = id(new PhabricatorMetaMTAMemberQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($phids) ->execute(); $this->recipientExpansionMap = $expansions; return $this; } /** * Expand a list of recipient PHIDs (possibly including aggregate recipients * like projects) into a deaggregated list of individual recipient PHIDs. * For example, this will expand project PHIDs into a list of the project's * members. * * @param list List of recipient PHIDs, possibly including aggregate * recipients. * @return list Deaggregated list of mailable recipients. */ private function expandRecipients(array $phids) { if ($this->recipientExpansionMap === null) { throw new Exception( pht( 'Call loadRecipientExpansions() before expandRecipients()!')); } $results = array(); foreach ($phids as $phid) { if (!isset($this->recipientExpansionMap[$phid])) { $results[$phid] = $phid; } else { foreach ($this->recipientExpansionMap[$phid] as $recipient_phid) { $results[$recipient_phid] = $recipient_phid; } } } return array_keys($results); } private function filterDeliverableActors(array $actors) { assert_instances_of($actors, 'PhabricatorMetaMTAActor'); $deliverable_actors = array(); foreach ($actors as $phid => $actor) { if ($actor->isDeliverable()) { $deliverable_actors[$phid] = $actor; } } return $deliverable_actors; } private function loadActors(array $actor_phids) { $actor_phids = array_filter($actor_phids); $viewer = PhabricatorUser::getOmnipotentUser(); $actors = id(new PhabricatorMetaMTAActorQuery()) ->setViewer($viewer) ->withPHIDs($actor_phids) ->execute(); if (!$actors) { return array(); } + if ($this->getForceDelivery()) { + // If we're forcing delivery, skip all the opt-out checks. + return $actors; + } + // Exclude explicit recipients. foreach ($this->getExcludeMailRecipientPHIDs() as $phid) { $actor = idx($actors, $phid); if (!$actor) { continue; } $actor->setUndeliverable( pht( 'This message is a response to another email message, and this '. 'recipient received the original email message, so we are not '. 'sending them this substantially similar message (for example, '. 'the sender used "Reply All" instead of "Reply" in response to '. 'mail from Phabricator).')); } // Exclude the actor if their preferences are set. $from_phid = $this->getParam('from'); $from_actor = idx($actors, $from_phid); if ($from_actor) { $from_user = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs(array($from_phid)) ->execute(); $from_user = head($from_user); if ($from_user) { $pref_key = PhabricatorUserPreferences::PREFERENCE_NO_SELF_MAIL; $exclude_self = $from_user ->loadPreferences() ->getPreference($pref_key); if ($exclude_self) { $from_actor->setUndeliverable( pht( 'This recipient is the user whose actions caused delivery of '. 'this message, but they have set preferences so they do not '. 'receive mail about their own actions (Settings > Email '. 'Preferences > Self Actions).')); } } } + $all_prefs = id(new PhabricatorUserPreferences())->loadAllWhere( + 'userPHID in (%Ls)', + $actor_phids); + $all_prefs = mpull($all_prefs, null, 'getUserPHID'); + + // Exclude recipients who don't want any mail. + foreach ($all_prefs as $phid => $prefs) { + $exclude = $prefs->getPreference( + PhabricatorUserPreferences::PREFERENCE_NO_MAIL, + false); + if ($exclude) { + $actors[$phid]->setUndeliverable( + pht( + 'This recipient has disabled all email notifications '. + '(Settings > Email Preferences > Email Notifications).')); + } + } // Exclude all recipients who have set preferences to not receive this type // of email (for example, a user who says they don't want emails about task // CC changes). $tags = $this->getParam('mailtags'); if ($tags) { - $all_prefs = id(new PhabricatorUserPreferences())->loadAllWhere( - 'userPHID in (%Ls)', - $actor_phids); - $all_prefs = mpull($all_prefs, null, 'getUserPHID'); - foreach ($all_prefs as $phid => $prefs) { $user_mailtags = $prefs->getPreference( PhabricatorUserPreferences::PREFERENCE_MAILTAGS, array()); // The user must have elected to receive mail for at least one // of the mailtags. $send = false; foreach ($tags as $tag) { if (idx($user_mailtags, $tag, true)) { $send = true; break; } } if (!$send) { $actors[$phid]->setUndeliverable( pht( 'This mail has tags which control which users receive it, and '. 'this recipient has not elected to receive mail with any of '. 'the tags on this message (Settings > Email Preferences).')); } } } return $actors; } private function shouldRateLimitMail(array $all_recipients) { try { PhabricatorSystemActionEngine::willTakeAction( $all_recipients, new PhabricatorMetaMTAErrorMailAction(), 1); return false; } catch (PhabricatorSystemActionRateLimitException $ex) { return true; } } } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php index b43502514a..742b4e1ee4 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php @@ -1,338 +1,339 @@ array( 'headers' => self::SERIALIZATION_JSON, 'bodies' => self::SERIALIZATION_JSON, 'attachments' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } public function setHeaders(array $headers) { // Normalize headers to lowercase. $normalized = array(); foreach ($headers as $name => $value) { $name = $this->normalizeMailHeaderName($name); if ($name == 'message-id') { $this->setMessageIDHash(PhabricatorHash::digestForIndex($value)); } $normalized[$name] = $value; } $this->headers = $normalized; return $this; } public function getHeader($key, $default = null) { $key = $this->normalizeMailHeaderName($key); return idx($this->headers, $key, $default); } private function normalizeMailHeaderName($name) { return strtolower($name); } public function getMessageID() { return $this->getHeader('Message-ID'); } public function getSubject() { return $this->getHeader('Subject'); } public function getCCAddresses() { return $this->getRawEmailAddresses(idx($this->headers, 'cc')); } public function getToAddresses() { return $this->getRawEmailAddresses(idx($this->headers, 'to')); } public function loadExcludeMailRecipientPHIDs() { $addresses = array_merge( $this->getToAddresses(), $this->getCCAddresses()); return $this->loadPHIDsFromAddresses($addresses); } final public function loadCCPHIDs() { return $this->loadPHIDsFromAddresses($this->getCCAddresses()); } private function loadPHIDsFromAddresses(array $addresses) { if (empty($addresses)) { return array(); } $users = id(new PhabricatorUserEmail()) ->loadAllWhere('address IN (%Ls)', $addresses); $user_phids = mpull($users, 'getUserPHID'); $mailing_lists = id(new PhabricatorMetaMTAMailingList()) ->loadAllWhere('email in (%Ls)', $addresses); $mailing_list_phids = mpull($mailing_lists, 'getPHID'); return array_merge($user_phids, $mailing_list_phids); } public function processReceivedMail() { try { $this->dropMailFromPhabricator(); $this->dropMailAlreadyReceived(); $receiver = $this->loadReceiver(); $sender = $receiver->loadSender($this); $receiver->validateSender($this, $sender); $this->setAuthorPHID($sender->getPHID()); $receiver->receiveMail($this, $sender); } catch (PhabricatorMetaMTAReceivedMailProcessingException $ex) { switch ($ex->getStatusCode()) { case MetaMTAReceivedMailStatus::STATUS_DUPLICATE: case MetaMTAReceivedMailStatus::STATUS_FROM_PHABRICATOR: // Don't send an error email back in these cases, since they're // very unlikely to be the sender's fault. break; case MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED: // This error is explicitly ignored. break; default: $this->sendExceptionMail($ex); break; } $this ->setStatus($ex->getStatusCode()) ->setMessage($ex->getMessage()) ->save(); return $this; } catch (Exception $ex) { $this->sendExceptionMail($ex); $this ->setStatus(MetaMTAReceivedMailStatus::STATUS_UNHANDLED_EXCEPTION) ->setMessage(pht('Unhandled Exception: %s', $ex->getMessage())) ->save(); throw $ex; } return $this->setMessage('OK')->save(); } public function getCleanTextBody() { $body = $this->getRawTextBody(); $parser = new PhabricatorMetaMTAEmailBodyParser(); return $parser->stripTextBody($body); } public function parseBody() { $body = $this->getRawTextBody(); $parser = new PhabricatorMetaMTAEmailBodyParser(); return $parser->parseBody($body); } public function getRawTextBody() { return idx($this->bodies, 'text'); } /** * Strip an email address down to the actual user@domain.tld part if * necessary, since sometimes it will have formatting like * '"Abraham Lincoln" '. */ private function getRawEmailAddress($address) { $matches = null; $ok = preg_match('/<(.*)>/', $address, $matches); if ($ok) { $address = $matches[1]; } return $address; } private function getRawEmailAddresses($addresses) { $raw_addresses = array(); foreach (explode(',', $addresses) as $address) { $raw_addresses[] = $this->getRawEmailAddress($address); } return array_filter($raw_addresses); } /** * If Phabricator sent the mail, always drop it immediately. This prevents * loops where, e.g., the public bug address is also a user email address * and creating a bug sends them an email, which loops. */ private function dropMailFromPhabricator() { if (!$this->getHeader('x-phabricator-sent-this-message')) { return; } throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_FROM_PHABRICATOR, pht( "Ignoring email with 'X-Phabricator-Sent-This-Message' header to ". "avoid loops.")); } /** * If this mail has the same message ID as some other mail, and isn't the * first mail we we received with that message ID, we drop it as a duplicate. */ private function dropMailAlreadyReceived() { $message_id_hash = $this->getMessageIDHash(); if (!$message_id_hash) { // No message ID hash, so we can't detect duplicates. This should only // happen with very old messages. return; } $messages = $this->loadAllWhere( 'messageIDHash = %s ORDER BY id ASC LIMIT 2', $message_id_hash); $messages_count = count($messages); if ($messages_count <= 1) { // If we only have one copy of this message, we're good to process it. return; } $first_message = reset($messages); if ($first_message->getID() == $this->getID()) { // If this is the first copy of the message, it is okay to process it. // We may not have been able to to process it immediately when we received // it, and could may have received several copies without processing any // yet. return; } $message = pht( 'Ignoring email with "Message-ID" hash "%s" that has been seen %d '. 'times, including this message.', $message_id_hash, $messages_count); throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_DUPLICATE, $message); } /** * Load a concrete instance of the @{class:PhabricatorMailReceiver} which * accepts this mail, if one exists. */ private function loadReceiver() { $receivers = id(new PhutilSymbolLoader()) ->setAncestorClass('PhabricatorMailReceiver') ->loadObjects(); $accept = array(); foreach ($receivers as $key => $receiver) { if (!$receiver->isEnabled()) { continue; } if ($receiver->canAcceptMail($this)) { $accept[$key] = $receiver; } } if (!$accept) { throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_NO_RECEIVERS, pht( 'Phabricator can not process this mail because no application '. 'knows how to handle it. Check that the address you sent it to is '. 'correct.'. "\n\n". '(No concrete, enabled subclass of PhabricatorMailReceiver can '. 'accept this mail.)')); } if (count($accept) > 1) { $names = implode(', ', array_keys($accept)); throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_ABUNDANT_RECEIVERS, pht( 'Phabricator is not able to process this mail because more than '. 'one application is willing to accept it, creating ambiguity. '. 'Mail needs to be accepted by exactly one receiving application.'. "\n\n". 'Accepting receivers: %s.', $names)); } return head($accept); } private function sendExceptionMail(Exception $ex) { $from = $this->getHeader('from'); if (!strlen($from)) { return; } if ($ex instanceof PhabricatorMetaMTAReceivedMailProcessingException) { $status_code = $ex->getStatusCode(); $status_name = MetaMTAReceivedMailStatus::getHumanReadableName( $status_code); $title = pht('Error Processing Mail (%s)', $status_name); $description = $ex->getMessage(); } else { $title = pht('Error Processing Mail (%s)', get_class($ex)); $description = pht('%s: %s', get_class($ex), $ex->getMessage()); } // TODO: Since headers don't necessarily have unique names, this may not // really be all the headers. It would be nice to pass the raw headers // through from the upper layers where possible. $headers = array(); foreach ($this->headers as $key => $value) { $headers[] = pht('%s: %s', $key, $value); } $headers = implode("\n", $headers); $body = pht(<<getRawTextBody(), $headers); $mail = id(new PhabricatorMetaMTAMail()) ->setIsErrorEmail(true) + ->setForceDelivery(true) ->setSubject($title) ->addRawTos(array($from)) ->setBody($body) ->saveAndSend(); } } diff --git a/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php b/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php index d3575097d5..5822b11a98 100644 --- a/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php +++ b/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php @@ -1,187 +1,212 @@ true, ); } public function testMailSendFailures() { $user = $this->generateNewTestUser(); $phid = $user->getPHID(); // Normally, the send should succeed. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailImplementationTestAdapter(); $mail->sendNow($force = true, $mailer); $this->assertEqual( PhabricatorMetaMTAMail::STATUS_SENT, $mail->getStatus()); // When the mailer fails temporarily, the mail should remain queued. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailImplementationTestAdapter(); $mailer->setFailTemporarily(true); try { $mail->sendNow($force = true, $mailer); } catch (Exception $ex) { // Ignore. } $this->assertEqual( PhabricatorMetaMTAMail::STATUS_QUEUE, $mail->getStatus()); // When the mailer fails permanently, the mail should be failed. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailImplementationTestAdapter(); $mailer->setFailPermanently(true); try { $mail->sendNow($force = true, $mailer); } catch (Exception $ex) { // Ignore. } $this->assertEqual( PhabricatorMetaMTAMail::STATUS_FAIL, $mail->getStatus()); } public function testRecipients() { $user = $this->generateNewTestUser(); $phid = $user->getPHID(); $prefs = $user->loadPreferences(); $mailer = new PhabricatorMailImplementationTestAdapter(); $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), '"To" is a recipient.'); - // Test that the "No Self Mail" preference works correctly. + // Test that the "No Self Mail" and "No Mail" preferences work correctly. $mail->setFrom($phid); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), '"From" does not exclude recipients by default.'); $prefs->setPreference( PhabricatorUserPreferences::PREFERENCE_NO_SELF_MAIL, true); $prefs->save(); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), '"From" excludes recipients with no-self-mail set.'); $prefs->unsetPreference( PhabricatorUserPreferences::PREFERENCE_NO_SELF_MAIL); $prefs->save(); + $this->assertTrue( + in_array($phid, $mail->buildRecipientList()), + '"From" does not exclude recipients by default.'); + + $prefs->setPreference( + PhabricatorUserPreferences::PREFERENCE_NO_MAIL, + true); + $prefs->save(); + + $this->assertFalse( + in_array($phid, $mail->buildRecipientList()), + '"From" excludes recipients with no-mail set.'); + + $mail->setForceDelivery(true); + + $this->assertTrue( + in_array($phid, $mail->buildRecipientList()), + '"From" includes no-mail recipients when forced.'); + + $mail->setForceDelivery(false); + + $prefs->unsetPreference( + PhabricatorUserPreferences::PREFERENCE_NO_MAIL); + $prefs->save(); + $this->assertTrue( in_array($phid, $mail->buildRecipientList()), '"From" does not exclude recipients by default.'); // Test that explicit exclusion works correctly. $mail->setExcludeMailRecipientPHIDs(array($phid)); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), 'Explicit exclude excludes recipients.'); $mail->setExcludeMailRecipientPHIDs(array()); // Test that mail tag preferences exclude recipients. $prefs->setPreference( PhabricatorUserPreferences::PREFERENCE_MAILTAGS, array( 'test-tag' => false, )); $prefs->save(); $mail->setMailTags(array('test-tag')); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), 'Tag preference excludes recipients.'); $prefs->unsetPreference(PhabricatorUserPreferences::PREFERENCE_MAILTAGS); $prefs->save(); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), 'Recipients restored after tag preference removed.'); } public function testThreadIDHeaders() { $this->runThreadIDHeadersWithConfiguration(true, true); $this->runThreadIDHeadersWithConfiguration(true, false); $this->runThreadIDHeadersWithConfiguration(false, true); $this->runThreadIDHeadersWithConfiguration(false, false); } private function runThreadIDHeadersWithConfiguration( $supports_message_id, $is_first_mail) { $mailer = new PhabricatorMailImplementationTestAdapter( array( 'supportsMessageIDHeader' => $supports_message_id, )); $thread_id = ''; $mail = new PhabricatorMetaMTAMail(); $mail->setThreadID($thread_id, $is_first_mail); $mail->sendNow($force = true, $mailer); $guts = $mailer->getGuts(); $dict = ipull($guts['headers'], 1, 0); if ($is_first_mail && $supports_message_id) { $expect_message_id = true; $expect_in_reply_to = false; $expect_references = false; } else { $expect_message_id = false; $expect_in_reply_to = true; $expect_references = true; } $case = ''; $this->assertTrue( isset($dict['Thread-Index']), "Expect Thread-Index header for case {$case}."); $this->assertEqual( $expect_message_id, isset($dict['Message-ID']), "Expectation about existence of Message-ID header for case {$case}."); $this->assertEqual( $expect_in_reply_to, isset($dict['In-Reply-To']), "Expectation about existence of In-Reply-To header for case {$case}."); $this->assertEqual( $expect_references, isset($dict['References']), "Expectation about existence of References header for case {$case}."); } } diff --git a/src/applications/people/controller/PhabricatorPeopleApproveController.php b/src/applications/people/controller/PhabricatorPeopleApproveController.php index 23d43da28d..ce1ac85dde 100644 --- a/src/applications/people/controller/PhabricatorPeopleApproveController.php +++ b/src/applications/people/controller/PhabricatorPeopleApproveController.php @@ -1,66 +1,67 @@ id = idx($data, 'id'); } public function processRequest() { $request = $this->getRequest(); $admin = $request->getUser(); $user = id(new PhabricatorPeopleQuery()) ->setViewer($admin) ->withIDs(array($this->id)) ->executeOne(); if (!$user) { return new Aphront404Response(); } $done_uri = $this->getApplicationURI('query/approval/'); if ($request->isFormPost()) { id(new PhabricatorUserEditor()) ->setActor($admin) ->approveUser($user, true); $title = pht( 'Phabricator Account "%s" Approved', $user->getUsername(), $admin->getUsername()); $body = pht( "Your Phabricator account (%s) has been approved by %s. You can ". "login here:\n\n %s\n\n", $user->getUsername(), $admin->getUsername(), PhabricatorEnv::getProductionURI('/')); $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($user->getPHID())) ->addCCs(array($admin->getPHID())) ->setSubject('[Phabricator] '.$title) + ->setForceDelivery(true) ->setBody($body) ->saveAndSend(); return id(new AphrontRedirectResponse())->setURI($done_uri); } $dialog = id(new AphrontDialogView()) ->setUser($admin) ->setTitle(pht('Confirm Approval')) ->appendChild( pht( 'Allow %s to access this Phabricator install?', phutil_tag('strong', array(), $user->getUsername()))) ->addCancelButton($done_uri) ->addSubmitButton(pht('Approve Account')); return id(new AphrontDialogResponse())->setDialog($dialog); } } diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php index 5a0d7e47f1..afd33dc4b0 100644 --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -1,890 +1,892 @@ timezoneIdentifier, date_default_timezone_get()); // Make sure these return booleans. case 'isAdmin': return (bool)$this->isAdmin; case 'isDisabled': return (bool)$this->isDisabled; case 'isSystemAgent': return (bool)$this->isSystemAgent; case 'isEmailVerified': return (bool)$this->isEmailVerified; case 'isApproved': return (bool)$this->isApproved; default: return parent::readField($field); } } /** * Is this a live account which has passed required approvals? Returns true * if this is an enabled, verified (if required), approved (if required) * account, and false otherwise. * * @return bool True if this is a standard, usable account. */ public function isUserActivated() { if ($this->getIsDisabled()) { return false; } if (!$this->getIsApproved()) { return false; } if (PhabricatorUserEmail::isEmailVerificationRequired()) { if (!$this->getIsEmailVerified()) { return false; } } return true; } /** * Returns `true` if this is a standard user who is logged in. Returns `false` * for logged out, anonymous, or external users. * * @return bool `true` if the user is a standard user who is logged in with * a normal session. */ public function getIsStandardUser() { $type_user = PhabricatorPeopleUserPHIDType::TYPECONST; return $this->getPHID() && (phid_get_type($this->getPHID()) == $type_user); } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPeopleUserPHIDType::TYPECONST); } public function setPassword(PhutilOpaqueEnvelope $envelope) { if (!$this->getPHID()) { throw new Exception( 'You can not set a password for an unsaved user because their PHID '. 'is a salt component in the password hash.'); } if (!strlen($envelope->openEnvelope())) { $this->setPasswordHash(''); } else { $this->setPasswordSalt(md5(Filesystem::readRandomBytes(32))); $hash = $this->hashPassword($envelope); $this->setPasswordHash($hash->openEnvelope()); } return $this; } // To satisfy PhutilPerson. public function getSex() { return $this->sex; } public function getMonogram() { return '@'.$this->getUsername(); } public function getTranslation() { try { if ($this->translation && class_exists($this->translation) && is_subclass_of($this->translation, 'PhabricatorTranslation')) { return $this->translation; } } catch (PhutilMissingSymbolException $ex) { return null; } return null; } public function isLoggedIn() { return !($this->getPHID() === null); } public function save() { if (!$this->getConduitCertificate()) { $this->setConduitCertificate($this->generateConduitCertificate()); } if (!strlen($this->getAccountSecret())) { $this->setAccountSecret(Filesystem::readRandomCharacters(64)); } $result = parent::save(); if ($this->profile) { $this->profile->save(); } $this->updateNameTokens(); id(new PhabricatorSearchIndexer()) ->queueDocumentForIndexing($this->getPHID()); return $result; } public function attachSession(PhabricatorAuthSession $session) { $this->session = $session; return $this; } public function getSession() { return $this->assertAttached($this->session); } public function hasSession() { return ($this->session !== self::ATTACHABLE); } private function generateConduitCertificate() { return Filesystem::readRandomCharacters(255); } public function comparePassword(PhutilOpaqueEnvelope $envelope) { if (!strlen($envelope->openEnvelope())) { return false; } if (!strlen($this->getPasswordHash())) { return false; } return PhabricatorPasswordHasher::comparePassword( $this->getPasswordHashInput($envelope), new PhutilOpaqueEnvelope($this->getPasswordHash())); } private function getPasswordHashInput(PhutilOpaqueEnvelope $password) { $input = $this->getUsername(). $password->openEnvelope(). $this->getPHID(). $this->getPasswordSalt(); return new PhutilOpaqueEnvelope($input); } private function hashPassword(PhutilOpaqueEnvelope $password) { $hasher = PhabricatorPasswordHasher::getBestHasher(); $input_envelope = $this->getPasswordHashInput($password); return $hasher->getPasswordHashForStorage($input_envelope); } const CSRF_CYCLE_FREQUENCY = 3600; const CSRF_SALT_LENGTH = 8; const CSRF_TOKEN_LENGTH = 16; const CSRF_BREACH_PREFIX = 'B@'; const EMAIL_CYCLE_FREQUENCY = 86400; const EMAIL_TOKEN_LENGTH = 24; private function getRawCSRFToken($offset = 0) { return $this->generateToken( time() + (self::CSRF_CYCLE_FREQUENCY * $offset), self::CSRF_CYCLE_FREQUENCY, PhabricatorEnv::getEnvConfig('phabricator.csrf-key'), self::CSRF_TOKEN_LENGTH); } /** * @phutil-external-symbol class PhabricatorStartup */ public function getCSRFToken() { $salt = PhabricatorStartup::getGlobal('csrf.salt'); if (!$salt) { $salt = Filesystem::readRandomCharacters(self::CSRF_SALT_LENGTH); PhabricatorStartup::setGlobal('csrf.salt', $salt); } // Generate a token hash to mitigate BREACH attacks against SSL. See // discussion in T3684. $token = $this->getRawCSRFToken(); $hash = PhabricatorHash::digest($token, $salt); return 'B@'.$salt.substr($hash, 0, self::CSRF_TOKEN_LENGTH); } public function validateCSRFToken($token) { $salt = null; $version = 'plain'; // This is a BREACH-mitigating token. See T3684. $breach_prefix = self::CSRF_BREACH_PREFIX; $breach_prelen = strlen($breach_prefix); if (!strncmp($token, $breach_prefix, $breach_prelen)) { $version = 'breach'; $salt = substr($token, $breach_prelen, self::CSRF_SALT_LENGTH); $token = substr($token, $breach_prelen + self::CSRF_SALT_LENGTH); } // When the user posts a form, we check that it contains a valid CSRF token. // Tokens cycle each hour (every CSRF_CYLCE_FREQUENCY seconds) and we accept // either the current token, the next token (users can submit a "future" // token if you have two web frontends that have some clock skew) or any of // the last 6 tokens. This means that pages are valid for up to 7 hours. // There is also some Javascript which periodically refreshes the CSRF // tokens on each page, so theoretically pages should be valid indefinitely. // However, this code may fail to run (if the user loses their internet // connection, or there's a JS problem, or they don't have JS enabled). // Choosing the size of the window in which we accept old CSRF tokens is // an issue of balancing concerns between security and usability. We could // choose a very narrow (e.g., 1-hour) window to reduce vulnerability to // attacks using captured CSRF tokens, but it's also more likely that real // users will be affected by this, e.g. if they close their laptop for an // hour, open it back up, and try to submit a form before the CSRF refresh // can kick in. Since the user experience of submitting a form with expired // CSRF is often quite bad (you basically lose data, or it's a big pain to // recover at least) and I believe we gain little additional protection // by keeping the window very short (the overwhelming value here is in // preventing blind attacks, and most attacks which can capture CSRF tokens // can also just capture authentication information [sniffing networks] // or act as the user [xss]) the 7 hour default seems like a reasonable // balance. Other major platforms have much longer CSRF token lifetimes, // like Rails (session duration) and Django (forever), which suggests this // is a reasonable analysis. $csrf_window = 6; for ($ii = -$csrf_window; $ii <= 1; $ii++) { $valid = $this->getRawCSRFToken($ii); switch ($version) { // TODO: We can remove this after the BREACH version has been in the // wild for a while. case 'plain': if ($token == $valid) { return true; } break; case 'breach': $digest = PhabricatorHash::digest($valid, $salt); if (substr($digest, 0, self::CSRF_TOKEN_LENGTH) == $token) { return true; } break; default: throw new Exception('Unknown CSRF token format!'); } } return false; } private function generateToken($epoch, $frequency, $key, $len) { if ($this->getPHID()) { $vec = $this->getPHID().$this->getAccountSecret(); } else { $vec = $this->getAlternateCSRFString(); } if ($this->hasSession()) { $vec = $vec.$this->getSession()->getSessionKey(); } $time_block = floor($epoch / $frequency); $vec = $vec.$key.$time_block; return substr(PhabricatorHash::digest($vec), 0, $len); } public function attachUserProfile(PhabricatorUserProfile $profile) { $this->profile = $profile; return $this; } public function loadUserProfile() { if ($this->profile) { return $this->profile; } $profile_dao = new PhabricatorUserProfile(); $this->profile = $profile_dao->loadOneWhere('userPHID = %s', $this->getPHID()); if (!$this->profile) { $profile_dao->setUserPHID($this->getPHID()); $this->profile = $profile_dao; } return $this->profile; } public function loadPrimaryEmailAddress() { $email = $this->loadPrimaryEmail(); if (!$email) { throw new Exception('User has no primary email address!'); } return $email->getAddress(); } public function loadPrimaryEmail() { return $this->loadOneRelative( new PhabricatorUserEmail(), 'userPHID', 'getPHID', '(isPrimary = 1)'); } public function loadPreferences() { if ($this->preferences) { return $this->preferences; } $preferences = null; if ($this->getPHID()) { $preferences = id(new PhabricatorUserPreferences())->loadOneWhere( 'userPHID = %s', $this->getPHID()); } if (!$preferences) { $preferences = new PhabricatorUserPreferences(); $preferences->setUserPHID($this->getPHID()); $default_dict = array( PhabricatorUserPreferences::PREFERENCE_TITLES => 'glyph', PhabricatorUserPreferences::PREFERENCE_EDITOR => '', PhabricatorUserPreferences::PREFERENCE_MONOSPACED => '', PhabricatorUserPreferences::PREFERENCE_DARK_CONSOLE => 0); $preferences->setPreferences($default_dict); } $this->preferences = $preferences; return $preferences; } public function loadEditorLink($path, $line, $callsign) { $editor = $this->loadPreferences()->getPreference( PhabricatorUserPreferences::PREFERENCE_EDITOR); if (is_array($path)) { $multiedit = $this->loadPreferences()->getPreference( PhabricatorUserPreferences::PREFERENCE_MULTIEDIT); switch ($multiedit) { case '': $path = implode(' ', $path); break; case 'disable': return null; } } if (!strlen($editor)) { return null; } $uri = strtr($editor, array( '%%' => '%', '%f' => phutil_escape_uri($path), '%l' => phutil_escape_uri($line), '%r' => phutil_escape_uri($callsign), )); // The resulting URI must have an allowed protocol. Otherwise, we'll return // a link to an error page explaining the misconfiguration. $ok = PhabricatorHelpEditorProtocolController::hasAllowedProtocol($uri); if (!$ok) { return '/help/editorprotocol/'; } return (string)$uri; } public function getAlternateCSRFString() { return $this->assertAttached($this->alternateCSRFString); } public function attachAlternateCSRFString($string) { $this->alternateCSRFString = $string; return $this; } /** * Populate the nametoken table, which used to fetch typeahead results. When * a user types "linc", we want to match "Abraham Lincoln" from on-demand * typeahead sources. To do this, we need a separate table of name fragments. */ public function updateNameTokens() { $table = self::NAMETOKEN_TABLE; $conn_w = $this->establishConnection('w'); $tokens = PhabricatorTypeaheadDatasource::tokenizeString( $this->getUserName().' '.$this->getRealName()); $sql = array(); foreach ($tokens as $token) { $sql[] = qsprintf( $conn_w, '(%d, %s)', $this->getID(), $token); } queryfx( $conn_w, 'DELETE FROM %T WHERE userID = %d', $table, $this->getID()); if ($sql) { queryfx( $conn_w, 'INSERT INTO %T (userID, token) VALUES %Q', $table, implode(', ', $sql)); } } public function sendWelcomeEmail(PhabricatorUser $admin) { $admin_username = $admin->getUserName(); $admin_realname = $admin->getRealName(); $user_username = $this->getUserName(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); $base_uri = PhabricatorEnv::getProductionURI('/'); $engine = new PhabricatorAuthSessionEngine(); $uri = $engine->getOneTimeLoginURI( $this, $this->loadPrimaryEmail(), PhabricatorAuthSessionEngine::ONETIME_WELCOME); $body = <<addTos(array($this->getPHID())) + ->setForceDelivery(true) ->setSubject('[Phabricator] Welcome to Phabricator') ->setBody($body) ->saveAndSend(); } public function sendUsernameChangeEmail( PhabricatorUser $admin, $old_username) { $admin_username = $admin->getUserName(); $admin_realname = $admin->getRealName(); $new_username = $this->getUserName(); $password_instructions = null; if (PhabricatorPasswordAuthProvider::getPasswordProvider()) { $engine = new PhabricatorAuthSessionEngine(); $uri = $engine->getOneTimeLoginURI( $this, null, PhabricatorAuthSessionEngine::ONETIME_USERNAME); $password_instructions = <<addTos(array($this->getPHID())) + ->setForceDelivery(true) ->setSubject('[Phabricator] Username Changed') ->setBody($body) ->saveAndSend(); } public static function describeValidUsername() { return pht( 'Usernames must contain only numbers, letters, period, underscore and '. 'hyphen, and can not end with a period. They must have no more than %d '. 'characters.', new PhutilNumber(self::MAXIMUM_USERNAME_LENGTH)); } public static function validateUsername($username) { // NOTE: If you update this, make sure to update: // // - Remarkup rule for @mentions. // - Routing rule for "/p/username/". // - Unit tests, obviously. // - describeValidUsername() method, above. if (strlen($username) > self::MAXIMUM_USERNAME_LENGTH) { return false; } return (bool)preg_match('/^[a-zA-Z0-9._-]*[a-zA-Z0-9_-]\z/', $username); } public static function getDefaultProfileImageURI() { return celerity_get_resource_uri('/rsrc/image/avatar.png'); } public function attachStatus(PhabricatorCalendarEvent $status) { $this->status = $status; return $this; } public function getStatus() { return $this->assertAttached($this->status); } public function hasStatus() { return $this->status !== self::ATTACHABLE; } public function attachProfileImageURI($uri) { $this->profileImage = $uri; return $this; } public function getProfileImageURI() { return $this->assertAttached($this->profileImage); } public function loadProfileImageURI() { if ($this->profileImage && ($this->profileImage !== self::ATTACHABLE)) { return $this->profileImage; } $src_phid = $this->getProfileImagePHID(); if ($src_phid) { // TODO: (T603) Can we get rid of this entirely and move it to // PeopleQuery with attach/attachable? $file = id(new PhabricatorFile())->loadOneWhere('phid = %s', $src_phid); if ($file) { $this->profileImage = $file->getBestURI(); return $this->profileImage; } } $this->profileImage = self::getDefaultProfileImageURI(); return $this->profileImage; } public function getFullName() { if (strlen($this->getRealName())) { return $this->getUsername().' ('.$this->getRealName().')'; } else { return $this->getUsername(); } } public function __toString() { return $this->getUsername(); } public static function loadOneWithEmailAddress($address) { $email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $address); if (!$email) { return null; } return id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $email->getUserPHID()); } /* -( Multi-Factor Authentication )---------------------------------------- */ /** * Update the flag storing this user's enrollment in multi-factor auth. * * With certain settings, we need to check if a user has MFA on every page, * so we cache MFA enrollment on the user object for performance. Calling this * method synchronizes the cache by examining enrollment records. After * updating the cache, use @{method:getIsEnrolledInMultiFactor} to check if * the user is enrolled. * * This method should be called after any changes are made to a given user's * multi-factor configuration. * * @return void * @task factors */ public function updateMultiFactorEnrollment() { $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $this->getPHID()); $enrolled = count($factors) ? 1 : 0; if ($enrolled !== $this->isEnrolledInMultiFactor) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); queryfx( $this->establishConnection('w'), 'UPDATE %T SET isEnrolledInMultiFactor = %d WHERE id = %d', $this->getTableName(), $enrolled, $this->getID()); unset($unguarded); $this->isEnrolledInMultiFactor = $enrolled; } } /** * Check if the user is enrolled in multi-factor authentication. * * Enrolled users have one or more multi-factor authentication sources * attached to their account. For performance, this value is cached. You * can use @{method:updateMultiFactorEnrollment} to update the cache. * * @return bool True if the user is enrolled. * @task factors */ public function getIsEnrolledInMultiFactor() { return $this->isEnrolledInMultiFactor; } /* -( Omnipotence )-------------------------------------------------------- */ /** * Returns true if this user is omnipotent. Omnipotent users bypass all policy * checks. * * @return bool True if the user bypasses policy checks. */ public function isOmnipotent() { return $this->omnipotent; } /** * Get an omnipotent user object for use in contexts where there is no acting * user, notably daemons. * * @return PhabricatorUser An omnipotent user. */ public static function getOmnipotentUser() { static $user = null; if (!$user) { $user = new PhabricatorUser(); $user->omnipotent = true; $user->makeEphemeral(); } return $user; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::POLICY_PUBLIC; case PhabricatorPolicyCapability::CAN_EDIT: if ($this->getIsSystemAgent()) { return PhabricatorPolicies::POLICY_ADMIN; } else { return PhabricatorPolicies::POLICY_NOONE; } } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getPHID() && ($viewer->getPHID() === $this->getPHID()); } public function describeAutomaticCapability($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_EDIT: return pht('Only you can edit your information.'); default: return null; } } /* -( PhabricatorCustomFieldInterface )------------------------------------ */ public function getCustomFieldSpecificationForRole($role) { return PhabricatorEnv::getEnvConfig('user.fields'); } public function getCustomFieldBaseClass() { return 'PhabricatorUserCustomField'; } public function getCustomFields() { return $this->assertAttached($this->customFields); } public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { $this->customFields = $fields; return $this; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $externals = id(new PhabricatorExternalAccount())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($externals as $external) { $external->delete(); } $prefs = id(new PhabricatorUserPreferences())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($prefs as $pref) { $pref->delete(); } $profiles = id(new PhabricatorUserProfile())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($profiles as $profile) { $profile->delete(); } $keys = id(new PhabricatorUserSSHKey())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($keys as $key) { $key->delete(); } $emails = id(new PhabricatorUserEmail())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($emails as $email) { $email->delete(); } $sessions = id(new PhabricatorAuthSession())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($sessions as $session) { $session->delete(); } $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($factors as $factor) { $factor->delete(); } $this->saveTransaction(); } } diff --git a/src/applications/people/storage/PhabricatorUserEmail.php b/src/applications/people/storage/PhabricatorUserEmail.php index 31c5377022..c7b5662b58 100644 --- a/src/applications/people/storage/PhabricatorUserEmail.php +++ b/src/applications/people/storage/PhabricatorUserEmail.php @@ -1,253 +1,256 @@ getVerificationCode().'/'; } public function save() { if (!$this->verificationCode) { $this->setVerificationCode(Filesystem::readRandomCharacters(24)); } return parent::save(); } /* -( Domain Restrictions )------------------------------------------------ */ /** * @task restrictions */ public static function isValidAddress($address) { if (strlen($address) > self::MAX_ADDRESS_LENGTH) { return false; } // Very roughly validate that this address isn't so mangled that a // reasonable piece of code might completely misparse it. In particular, // the major risks are: // // - `PhutilEmailAddress` needs to be able to extract the domain portion // from it. // - Reasonable mail adapters should be hard-pressed to interpret one // address as several addresses. // // To this end, we're roughly verifying that there's some normal text, an // "@" symbol, and then some more normal text. $email_regex = '(^[a-z0-9_+.!-]+@[a-z0-9_+:.-]+\z)i'; if (!preg_match($email_regex, $address)) { return false; } return true; } /** * @task restrictions */ public static function describeValidAddresses() { return pht( "Email addresses should be in the form 'user@domain.com'. The maximum ". "length of an email address is %d character(s).", new PhutilNumber(self::MAX_ADDRESS_LENGTH)); } /** * @task restrictions */ public static function isAllowedAddress($address) { if (!self::isValidAddress($address)) { return false; } $allowed_domains = PhabricatorEnv::getEnvConfig('auth.email-domains'); if (!$allowed_domains) { return true; } $addr_obj = new PhutilEmailAddress($address); $domain = $addr_obj->getDomainName(); if (!$domain) { return false; } $lower_domain = phutil_utf8_strtolower($domain); foreach ($allowed_domains as $allowed_domain) { $lower_allowed = phutil_utf8_strtolower($allowed_domain); if ($lower_allowed === $lower_domain) { return true; } } return false; } /** * @task restrictions */ public static function describeAllowedAddresses() { $domains = PhabricatorEnv::getEnvConfig('auth.email-domains'); if (!$domains) { return null; } if (count($domains) == 1) { return 'Email address must be @'.head($domains); } else { return 'Email address must be at one of: '. implode(', ', $domains); } } /** * Check if this install requires email verification. * * @return bool True if email addresses must be verified. * * @task restrictions */ public static function isEmailVerificationRequired() { // NOTE: Configuring required email domains implies required verification. return PhabricatorEnv::getEnvConfig('auth.require-email-verification') || PhabricatorEnv::getEnvConfig('auth.email-domains'); } /* -( Email About Email )-------------------------------------------------- */ /** * Send a verification email from $user to this address. * * @param PhabricatorUser The user sending the verification. * @return this * @task email */ public function sendVerificationEmail(PhabricatorUser $user) { $username = $user->getUsername(); $address = $this->getAddress(); $link = PhabricatorEnv::getProductionURI($this->getVerificationURI()); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); $signature = null; if (!$is_serious) { $signature = <<addRawTos(array($address)) + ->setForceDelivery(true) ->setSubject('[Phabricator] Email Verification') ->setBody($body) ->setRelatedPHID($user->getPHID()) ->saveAndSend(); return $this; } /** * Send a notification email from $user to this address, informing the * recipient that this is no longer their account's primary address. * * @param PhabricatorUser The user sending the notification. * @param PhabricatorUserEmail New primary email address. * @return this * @task email */ public function sendOldPrimaryEmail( PhabricatorUser $user, PhabricatorUserEmail $new) { $username = $user->getUsername(); $old_address = $this->getAddress(); $new_address = $new->getAddress(); $body = <<addRawTos(array($old_address)) + ->setForceDelivery(true) ->setSubject('[Phabricator] Primary Address Changed') ->setBody($body) ->setFrom($user->getPHID()) ->setRelatedPHID($user->getPHID()) ->saveAndSend(); } /** * Send a notification email from $user to this address, informing the * recipient that this is now their account's new primary email address. * * @param PhabricatorUser The user sending the verification. * @return this * @task email */ public function sendNewPrimaryEmail(PhabricatorUser $user) { $username = $user->getUsername(); $new_address = $this->getAddress(); $body = <<addRawTos(array($new_address)) + ->setForceDelivery(true) ->setSubject('[Phabricator] Primary Address Changed') ->setBody($body) ->setFrom($user->getPHID()) ->setRelatedPHID($user->getPHID()) ->saveAndSend(); return $this; } } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelEmailPreferences.php b/src/applications/settings/panel/PhabricatorSettingsPanelEmailPreferences.php index 1000c3fe13..1f5b3d77b2 100644 --- a/src/applications/settings/panel/PhabricatorSettingsPanelEmailPreferences.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelEmailPreferences.php @@ -1,241 +1,272 @@ getUser(); $preferences = $user->loadPreferences(); + $pref_no_mail = PhabricatorUserPreferences::PREFERENCE_NO_MAIL; $pref_no_self_mail = PhabricatorUserPreferences::PREFERENCE_NO_SELF_MAIL; $errors = array(); if ($request->isFormPost()) { + $preferences->setPreference( + $pref_no_mail, + $request->getStr($pref_no_mail)); + $preferences->setPreference( $pref_no_self_mail, $request->getStr($pref_no_self_mail)); $new_tags = $request->getArr('mailtags'); $mailtags = $preferences->getPreference('mailtags', array()); $all_tags = $this->getMailTags(); $maniphest = 'PhabricatorManiphestApplication'; if (!PhabricatorApplication::isClassInstalled($maniphest)) { $all_tags = array_diff_key($all_tags, $this->getManiphestMailTags()); } $pholio = 'PhabricatorPholioApplication'; if (!PhabricatorApplication::isClassInstalled($pholio)) { $all_tags = array_diff_key($all_tags, $this->getPholioMailTags()); } foreach ($all_tags as $key => $label) { $mailtags[$key] = (bool)idx($new_tags, $key, false); } $preferences->setPreference('mailtags', $mailtags); $preferences->save(); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI('?saved=true')); } $form = new AphrontFormView(); $form ->setUser($user) + ->appendRemarkupInstructions( + pht( + 'These settings let you control how Phabricator notifies you about '. + 'events. You can configure Phabricator to send you an email, '. + 'just send a web notification, or not notify you at all.')) + ->appendRemarkupInstructions( + pht( + 'If you disable **Email Notifications**, Phabricator will never '. + 'send email to notify you about events. This preference overrides '. + 'all your other settings.'. + "\n\n". + "//You may still receive some administrative email, like password ". + "reset email.//")) + ->appendChild( + id(new AphrontFormSelectControl()) + ->setLabel(pht('Email Notifications')) + ->setName($pref_no_mail) + ->setOptions( + array( + '0' => pht('Send me email notifications'), + '1' => pht('Never send email notifications'), + )) + ->setValue($preferences->getPreference($pref_no_mail, 0))) + ->appendRemarkupInstructions( + pht( + 'If you disable **Self Actions**, Phabricator will not notify '. + 'you about actions you take.')) ->appendChild( id(new AphrontFormSelectControl()) ->setLabel(pht('Self Actions')) ->setName($pref_no_self_mail) ->setOptions( array( '0' => pht('Send me an email when I take an action'), '1' => pht('Do not send me an email when I take an action'), )) - ->setCaption(pht('You can disable email about your own actions.')) ->setValue($preferences->getPreference($pref_no_self_mail, 0))); $mailtags = $preferences->getPreference('mailtags', array()); $form->appendChild( id(new PHUIFormDividerControl())); $form->appendRemarkupInstructions( pht( 'You can customize which kinds of events you receive email for '. 'here. If you turn off email for a certain type of event, you '. 'will receive an unread notification in Phabricator instead.'. "\n\n". 'Phabricator notifications (shown in the menu bar) which you receive '. 'an email for are marked read by default in Phabricator. If you turn '. 'off email for a certain type of event, the corresponding '. 'notification will not be marked read.'. "\n\n". 'Note that if an update makes several changes (like adding CCs to a '. 'task, closing it, and adding a comment) you will still receive '. 'an email as long as at least one of the changes is set to notify '. 'you.'. "\n\n". 'These preferences **only** apply to objects you are connected to '. '(for example, Revisions where you are a reviewer or tasks you are '. 'CC\'d on). To receive email alerts when other objects are created, '. 'configure [[ /herald/ | Herald Rules ]].'. "\n\n". '**Phabricator will send an email to your primary account when:**')); if (PhabricatorApplication::isClassInstalledForViewer( 'PhabricatorDifferentialApplication', $user)) { $form ->appendChild( $this->buildMailTagCheckboxes( $this->getDifferentialMailTags(), $mailtags) ->setLabel(pht('Differential'))); } if (PhabricatorApplication::isClassInstalledForViewer( 'PhabricatorManiphestApplication', $user)) { $form->appendChild( $this->buildMailTagCheckboxes( $this->getManiphestMailTags(), $mailtags) ->setLabel(pht('Maniphest'))); } if (PhabricatorApplication::isClassInstalledForViewer( 'PhabricatorPholioApplication', $user)) { $form->appendChild( $this->buildMailTagCheckboxes( $this->getPholioMailTags(), $mailtags) ->setLabel(pht('Pholio'))); } $form ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Save Preferences'))); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Email Preferences')) ->setFormSaved($request->getStr('saved')) ->setFormErrors($errors) ->setForm($form); return id(new AphrontNullView()) ->appendChild( array( $form_box, )); } private function getMailTags() { return array( MetaMTANotificationType::TYPE_DIFFERENTIAL_REVIEW_REQUEST => pht('A revision is created.'), MetaMTANotificationType::TYPE_DIFFERENTIAL_UPDATED => pht('A revision is updated.'), MetaMTANotificationType::TYPE_DIFFERENTIAL_COMMENT => pht('Someone comments on a revision.'), MetaMTANotificationType::TYPE_DIFFERENTIAL_REVIEWERS => pht("A revision's reviewers change."), MetaMTANotificationType::TYPE_DIFFERENTIAL_CLOSED => pht('A revision is closed.'), MetaMTANotificationType::TYPE_DIFFERENTIAL_CC => pht("A revision's CCs change."), MetaMTANotificationType::TYPE_DIFFERENTIAL_OTHER => pht('Other revision activity not listed above occurs.'), MetaMTANotificationType::TYPE_MANIPHEST_STATUS => pht("A task's status changes."), MetaMTANotificationType::TYPE_MANIPHEST_OWNER => pht("A task's owner changes."), MetaMTANotificationType::TYPE_MANIPHEST_COMMENT => pht('Someone comments on a task.'), MetaMTANotificationType::TYPE_MANIPHEST_PRIORITY => pht("A task's priority changes."), MetaMTANotificationType::TYPE_MANIPHEST_CC => pht("A task's CCs change."), MetaMTANotificationType::TYPE_MANIPHEST_PROJECTS => pht("A task's associated projects change."), MetaMTANotificationType::TYPE_MANIPHEST_OTHER => pht('Other task activity not listed above occurs.'), MetaMTANotificationType::TYPE_PHOLIO_STATUS => pht("A mock's status changes."), MetaMTANotificationType::TYPE_PHOLIO_COMMENT => pht('Someone comments on a mock.'), MetaMTANotificationType::TYPE_PHOLIO_UPDATED => pht('Mock images or descriptions change.'), MetaMTANotificationType::TYPE_PHOLIO_OTHER => pht('Other mock activity not listed above occurs.'), ); } private function getManiphestMailTags() { return array_select_keys( $this->getMailTags(), array( MetaMTANotificationType::TYPE_MANIPHEST_STATUS, MetaMTANotificationType::TYPE_MANIPHEST_OWNER, MetaMTANotificationType::TYPE_MANIPHEST_PRIORITY, MetaMTANotificationType::TYPE_MANIPHEST_CC, MetaMTANotificationType::TYPE_MANIPHEST_PROJECTS, MetaMTANotificationType::TYPE_MANIPHEST_COMMENT, MetaMTANotificationType::TYPE_MANIPHEST_OTHER, )); } private function getDifferentialMailTags() { return array_select_keys( $this->getMailTags(), array( MetaMTANotificationType::TYPE_DIFFERENTIAL_REVIEW_REQUEST, MetaMTANotificationType::TYPE_DIFFERENTIAL_UPDATED, MetaMTANotificationType::TYPE_DIFFERENTIAL_COMMENT, MetaMTANotificationType::TYPE_DIFFERENTIAL_CLOSED, MetaMTANotificationType::TYPE_DIFFERENTIAL_REVIEWERS, MetaMTANotificationType::TYPE_DIFFERENTIAL_CC, MetaMTANotificationType::TYPE_DIFFERENTIAL_OTHER, )); } private function getPholioMailTags() { return array_select_keys( $this->getMailTags(), array( MetaMTANotificationType::TYPE_PHOLIO_STATUS, MetaMTANotificationType::TYPE_PHOLIO_COMMENT, MetaMTANotificationType::TYPE_PHOLIO_UPDATED, MetaMTANotificationType::TYPE_PHOLIO_OTHER, )); } private function buildMailTagCheckboxes( array $tags, array $prefs) { $control = new AphrontFormCheckboxControl(); foreach ($tags as $key => $label) { $control->addCheckbox( 'mailtags['.$key.']', 1, $label, idx($prefs, $key, 1)); } return $control; } } diff --git a/src/applications/settings/storage/PhabricatorUserPreferences.php b/src/applications/settings/storage/PhabricatorUserPreferences.php index 78e006089b..f4e931bf6a 100644 --- a/src/applications/settings/storage/PhabricatorUserPreferences.php +++ b/src/applications/settings/storage/PhabricatorUserPreferences.php @@ -1,89 +1,90 @@ array( 'preferences' => self::SERIALIZATION_JSON, ), self::CONFIG_TIMESTAMPS => false, ) + parent::getConfiguration(); } public function getPreference($key, $default = null) { return idx($this->preferences, $key, $default); } public function setPreference($key, $value) { $this->preferences[$key] = $value; return $this; } public function unsetPreference($key) { unset($this->preferences[$key]); return $this; } public function getPinnedApplications(array $apps, PhabricatorUser $viewer) { $pref_pinned = PhabricatorUserPreferences::PREFERENCE_APP_PINNED; $pinned = $this->getPreference($pref_pinned); if ($pinned) { return $pinned; } $pref_tiles = PhabricatorUserPreferences::PREFERENCE_APP_TILES; $tiles = $this->getPreference($pref_tiles, array()); $full_tile = 'full'; $large = array(); foreach ($apps as $app) { $show = $app->isPinnedByDefault($viewer); // TODO: This is legacy stuff, clean it up eventually. This approximately // retains the old "tiles" preference. if (isset($tiles[get_class($app)])) { $show = ($tiles[get_class($app)] == $full_tile); } if ($show) { $large[] = get_class($app); } } return $large; } }