diff --git a/src/applications/harbormaster/query/HarbormasterBuildPlanQuery.php b/src/applications/harbormaster/query/HarbormasterBuildPlanQuery.php index c903fbb37f..262aa12d7b 100644 --- a/src/applications/harbormaster/query/HarbormasterBuildPlanQuery.php +++ b/src/applications/harbormaster/query/HarbormasterBuildPlanQuery.php @@ -1,152 +1,152 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withStatuses(array $statuses) { $this->statuses = $statuses; return $this; } public function withDatasourceQuery($query) { $this->datasourceQuery = $query; return $this; } public function withPlanAutoKeys(array $keys) { $this->planAutoKeys = $keys; return $this; } public function withNameNgrams($ngrams) { return $this->withNgramsConstraint( new HarbormasterBuildPlanNameNgrams(), $ngrams); } public function needBuildSteps($need) { $this->needBuildSteps = $need; return $this; } public function newResultObject() { return new HarbormasterBuildPlan(); } protected function loadPage() { return $this->loadStandardPage($this->newResultObject()); } protected function didFilterPage(array $page) { if ($this->needBuildSteps) { $plan_phids = mpull($page, 'getPHID'); $steps = id(new HarbormasterBuildStepQuery()) ->setParentQuery($this) ->setViewer($this->getViewer()) ->withBuildPlanPHIDs($plan_phids) ->execute(); $steps = mgroup($steps, 'getBuildPlanPHID'); foreach ($page as $plan) { $plan_steps = idx($steps, $plan->getPHID(), array()); $plan->attachBuildSteps($plan_steps); } } return $page; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->ids !== null) { $where[] = qsprintf( $conn, 'plan.id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'plan.phid IN (%Ls)', $this->phids); } if ($this->statuses !== null) { $where[] = qsprintf( $conn, 'plan.planStatus IN (%Ls)', $this->statuses); } - if (strlen($this->datasourceQuery)) { + if (!phutil_nonempty_string($this->datasourceQuery)) { $where[] = qsprintf( $conn, 'plan.name LIKE %>', $this->datasourceQuery); } if ($this->planAutoKeys !== null) { $where[] = qsprintf( $conn, 'plan.planAutoKey IN (%Ls)', $this->planAutoKeys); } return $where; } protected function getPrimaryTableAlias() { return 'plan'; } public function getQueryApplicationClass() { return 'PhabricatorHarbormasterApplication'; } public function getOrderableColumns() { return parent::getOrderableColumns() + array( 'name' => array( 'column' => 'name', 'type' => 'string', 'reverse' => true, ), ); } protected function newPagingMapFromPartialObject($object) { return array( 'id' => (int)$object->getID(), 'name' => $object->getName(), ); } public function getBuiltinOrders() { return array( 'name' => array( 'vector' => array('name', 'id'), 'name' => pht('Name'), ), ) + parent::getBuiltinOrders(); } } diff --git a/src/applications/harbormaster/step/HarbormasterUploadArtifactBuildStepImplementation.php b/src/applications/harbormaster/step/HarbormasterUploadArtifactBuildStepImplementation.php index f8bdb7fdfd..0779ccb186 100644 --- a/src/applications/harbormaster/step/HarbormasterUploadArtifactBuildStepImplementation.php +++ b/src/applications/harbormaster/step/HarbormasterUploadArtifactBuildStepImplementation.php @@ -1,97 +1,97 @@ formatSettingForDescription('path'), $this->formatSettingForDescription('hostartifact')); } public function execute( HarbormasterBuild $build, HarbormasterBuildTarget $build_target) { $viewer = PhabricatorUser::getOmnipotentUser(); $settings = $this->getSettings(); $variables = $build_target->getVariables(); $path = $this->mergeVariables( 'vsprintf', $settings['path'], $variables); $artifact = $build_target->loadArtifact($settings['hostartifact']); $impl = $artifact->getArtifactImplementation(); $lease = $impl->loadArtifactLease($viewer); $interface = $lease->getInterface('filesystem'); // TODO: Handle exceptions. $file = $interface->saveFile($path, $settings['name']); // Insert the artifact record. $artifact = $build_target->createArtifact( $viewer, $settings['name'], HarbormasterFileArtifact::ARTIFACTCONST, array( 'filePHID' => $file->getPHID(), )); } public function getArtifactInputs() { return array( array( 'name' => pht('Upload From Host'), 'key' => $this->getSetting('hostartifact'), 'type' => HarbormasterHostArtifact::ARTIFACTCONST, ), ); } public function getArtifactOutputs() { return array( array( 'name' => pht('Uploaded File'), 'key' => $this->getSetting('name'), 'type' => HarbormasterHostArtifact::ARTIFACTCONST, ), ); } public function getFieldSpecifications() { return array( 'path' => array( 'name' => pht('Path'), 'type' => 'text', 'required' => true, ), 'name' => array( 'name' => pht('Local Name'), 'type' => 'text', 'required' => true, ), 'hostartifact' => array( 'name' => pht('Host Artifact'), 'type' => 'text', 'required' => true, ), ); } } diff --git a/src/applications/legalpad/editor/LegalpadDocumentEditEngine.php b/src/applications/legalpad/editor/LegalpadDocumentEditEngine.php index 814647b82a..fb3f54275a 100644 --- a/src/applications/legalpad/editor/LegalpadDocumentEditEngine.php +++ b/src/applications/legalpad/editor/LegalpadDocumentEditEngine.php @@ -1,169 +1,169 @@ getViewer(); $document = LegalpadDocument::initializeNewDocument($viewer); $body = id(new LegalpadDocumentBody()) ->setCreatorPHID($viewer->getPHID()); $document->attachDocumentBody($body); $document->setDocumentBodyPHID(PhabricatorPHIDConstants::PHID_VOID); return $document; } protected function newObjectQuery() { return id(new LegalpadDocumentQuery()) ->needDocumentBodies(true); } protected function getObjectCreateTitleText($object) { return pht('Create New Document'); } protected function getObjectEditTitleText($object) { $body = $object->getDocumentBody(); $title = $body->getTitle(); return pht('Edit Document: %s', $title); } protected function getObjectEditShortText($object) { $body = $object->getDocumentBody(); return $body->getTitle(); } protected function getObjectCreateShortText() { return pht('Create Document'); } protected function getObjectName() { return pht('Document'); } protected function getObjectCreateCancelURI($object) { return $this->getApplication()->getApplicationURI('/'); } protected function getEditorURI() { return $this->getApplication()->getApplicationURI('edit/'); } protected function getObjectViewURI($object) { $id = $object->getID(); return $this->getApplication()->getApplicationURI('view/'.$id.'/'); } protected function getCreateNewObjectPolicy() { return $this->getApplication()->getPolicy( LegalpadCreateDocumentsCapability::CAPABILITY); } protected function buildCustomEditFields($object) { $viewer = $this->getViewer(); $body = $object->getDocumentBody(); $document_body = $body->getText(); $is_create = $this->getIsCreate(); $is_admin = $viewer->getIsAdmin(); $fields = array(); $fields[] = id(new PhabricatorTextEditField()) ->setKey('title') ->setLabel(pht('Title')) ->setDescription(pht('Document Title.')) ->setConduitTypeDescription(pht('New document title.')) ->setValue($object->getTitle()) ->setIsRequired(true) ->setTransactionType( LegalpadDocumentTitleTransaction::TRANSACTIONTYPE); if ($is_create) { $fields[] = id(new PhabricatorSelectEditField()) ->setKey('signatureType') ->setLabel(pht('Who Should Sign?')) ->setDescription(pht('Type of signature required')) ->setConduitTypeDescription(pht('New document signature type.')) ->setValue($object->getSignatureType()) ->setOptions(LegalpadDocument::getSignatureTypeMap()) ->setTransactionType( LegalpadDocumentSignatureTypeTransaction::TRANSACTIONTYPE); $show_require = true; } else { $fields[] = id(new PhabricatorStaticEditField()) ->setLabel(pht('Who Should Sign?')) ->setValue($object->getSignatureTypeName()); $individual = LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL; $show_require = $object->getSignatureType() == $individual; } if ($show_require && $is_admin) { $fields[] = id(new PhabricatorBoolEditField()) ->setKey('requireSignature') ->setOptions( pht('No Signature Required'), - pht('Signature Required to use Phabricator')) + pht('Signature Required to Log In')) ->setAsCheckbox(true) ->setTransactionType( LegalpadDocumentRequireSignatureTransaction::TRANSACTIONTYPE) ->setDescription(pht('Marks this document as required signing.')) ->setConduitDescription( pht('Marks this document as required signing.')) ->setValue($object->getRequireSignature()); } $fields[] = id(new PhabricatorRemarkupEditField()) ->setKey('preamble') ->setLabel(pht('Preamble')) ->setDescription(pht('The preamble of the document.')) ->setConduitTypeDescription(pht('New document preamble.')) ->setValue($object->getPreamble()) ->setTransactionType( LegalpadDocumentPreambleTransaction::TRANSACTIONTYPE); $fields[] = id(new PhabricatorRemarkupEditField()) ->setKey('text') ->setLabel(pht('Document Body')) ->setDescription(pht('The body of text of the document.')) ->setConduitTypeDescription(pht('New document body.')) ->setValue($document_body) ->setIsRequired(true) ->setTransactionType( LegalpadDocumentTextTransaction::TRANSACTIONTYPE); return $fields; } } diff --git a/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php b/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php index 60a41a26ca..9bb3987415 100644 --- a/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php +++ b/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php @@ -1,530 +1,530 @@ array( 'name' => pht('Unbreak Now!'), 'keywords' => array('unbreak'), 'short' => pht('Unbreak!'), 'color' => 'pink', ), 90 => array( 'name' => pht('Needs Triage'), 'keywords' => array('triage'), 'short' => pht('Triage'), 'color' => 'violet', ), 80 => array( 'name' => pht('High'), 'keywords' => array('high'), 'short' => pht('High'), 'color' => 'red', ), 50 => array( 'name' => pht('Normal'), 'keywords' => array('normal'), 'short' => pht('Normal'), 'color' => 'orange', ), 25 => array( 'name' => pht('Low'), 'keywords' => array('low'), 'short' => pht('Low'), 'color' => 'yellow', ), 0 => array( 'name' => pht('Wishlist'), 'keywords' => array('wish', 'wishlist'), 'short' => pht('Wish'), 'color' => 'sky', ), ); $status_type = 'maniphest.statuses'; $status_defaults = array( 'open' => array( 'name' => pht('Open'), 'special' => ManiphestTaskStatus::SPECIAL_DEFAULT, 'prefixes' => array( 'open', 'opens', 'reopen', 'reopens', ), ), 'resolved' => array( 'name' => pht('Resolved'), 'name.full' => pht('Closed, Resolved'), 'closed' => true, 'special' => ManiphestTaskStatus::SPECIAL_CLOSED, 'transaction.icon' => 'fa-check-circle', 'prefixes' => array( 'closed', 'closes', 'close', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved', ), 'suffixes' => array( 'as resolved', 'as fixed', ), 'keywords' => array('closed', 'fixed', 'resolved'), ), 'wontfix' => array( 'name' => pht('Wontfix'), 'name.full' => pht('Closed, Wontfix'), 'transaction.icon' => 'fa-ban', 'closed' => true, 'prefixes' => array( 'wontfix', 'wontfixes', 'wontfixed', ), 'suffixes' => array( 'as wontfix', ), ), 'invalid' => array( 'name' => pht('Invalid'), 'name.full' => pht('Closed, Invalid'), 'transaction.icon' => 'fa-minus-circle', 'closed' => true, 'claim' => false, 'prefixes' => array( 'invalidate', 'invalidates', 'invalidated', ), 'suffixes' => array( 'as invalid', ), ), 'duplicate' => array( 'name' => pht('Duplicate'), 'name.full' => pht('Closed, Duplicate'), 'transaction.icon' => 'fa-files-o', 'special' => ManiphestTaskStatus::SPECIAL_DUPLICATE, 'closed' => true, 'claim' => false, ), 'spite' => array( 'name' => pht('Spite'), 'name.full' => pht('Closed, Spite'), 'name.action' => pht('Spited'), 'transaction.icon' => 'fa-thumbs-o-down', 'silly' => true, 'closed' => true, 'prefixes' => array( 'spite', 'spites', 'spited', ), 'suffixes' => array( 'out of spite', 'as spite', ), ), ); $status_description = $this->deformat(pht(<<.// Allows you to specify a list of text prefixes which will trigger a task transition into this status when mentioned in a commit message. For example, providing "closes" here will allow users to move tasks to this status by writing `Closes T123` in commit messages. - `suffixes` //Optional list.// Allows you to specify a list of text suffixes which will trigger a task transition into this status when mentioned in a commit message, after a valid prefix. For example, providing "as invalid" here will allow users to move tasks to this status by writing `Closes T123 as invalid`, even if another status is selected by the "Closes" prefix. - `keywords` //Optional list.// Allows you to specify a list of keywords which can be used with `!status` commands in email to select this status. - `disabled` //Optional bool.// Marks this status as no longer in use so tasks can not be created or edited to have this status. Existing tasks with this status will not be affected, but you can batch edit them or let them die out on their own. - `claim` //Optional bool.// By default, closing an unassigned task claims it. You can set this to `false` to disable this behavior for a particular status. - `locked` //Optional string.// Lock tasks in this status. Specify "comments" to lock comments (users who can edit the task may override this lock). Specify "edits" to prevent anyone except the task owner from making edits. - `mfa` //Optional bool.// Require all edits to this task to be signed with multi-factor authentication. Statuses will appear in the UI in the order specified. Note the status marked `special` as `duplicate` is not settable directly and will not appear in UI -elements, and that any status marked `silly` does not appear if Phabricator +elements, and that any status marked `silly` does not appear if the software is configured with `phabricator.serious-business` set to true. Examining the default configuration and examples below will probably be helpful in understanding these options. EOTEXT )); $status_example = array( 'open' => array( 'name' => pht('Open'), 'special' => 'default', ), 'closed' => array( 'name' => pht('Closed'), 'special' => 'closed', 'closed' => true, ), 'duplicate' => array( 'name' => pht('Duplicate'), 'special' => 'duplicate', 'closed' => true, ), ); $json = new PhutilJSON(); $status_example = $json->encodeFormatted($status_example); // This is intentionally blank for now, until we can move more Maniphest // logic to custom fields. $default_fields = array(); foreach ($default_fields as $key => $enabled) { $default_fields[$key] = array( 'disabled' => !$enabled, ); } $custom_field_type = 'custom:PhabricatorCustomFieldConfigOptionType'; $fields_example = array( 'mycompany.estimated-hours' => array( 'name' => pht('Estimated Hours'), 'type' => 'int', 'caption' => pht('Estimated number of hours this will take.'), ), ); $fields_json = id(new PhutilJSON())->encodeFormatted($fields_example); $points_type = 'maniphest.points'; $points_example_1 = array( 'enabled' => true, 'label' => pht('Story Points'), 'action' => pht('Change Story Points'), ); $points_json_1 = id(new PhutilJSON())->encodeFormatted($points_example_1); $points_example_2 = array( 'enabled' => true, 'label' => pht('Estimated Hours'), 'action' => pht('Change Estimate'), ); $points_json_2 = id(new PhutilJSON())->encodeFormatted($points_example_2); $points_description = $this->deformat(pht(<< $subtype_default_key, 'name' => pht('Task'), ), array( 'key' => 'bug', 'name' => pht('Bug'), ), array( 'key' => 'feature', 'name' => pht('Feature Request'), ), ); $subtype_example = id(new PhutilJSON())->encodeAsList($subtype_example); $subtype_default = array( array( 'key' => $subtype_default_key, 'name' => pht('Task'), ), ); $subtype_description = $this->deformat(pht(<<.// Show users creation forms for these task subtypes. - `forms`: //Optional list.// Show users these specific forms, in order. If you don't specify either constraint, users will be shown creation forms for the same subtype. For example, if you have a "quest" subtype and do not configure `children`, users who click "Create Subtask" will be presented with all create forms for "quest" tasks. If you want to present them with forms for a different task subtype or set of subtypes instead, use `subtypes`: ``` { ... "children": { "subtypes": ["objective", "boss", "reward"] } ... } ``` If you want to present them with specific forms, use `forms` and specify form IDs: ``` { ... "children": { "forms": [12, 16] } ... } ``` When specifying forms by ID explicitly, the order you specify the forms in will be used when presenting options to the user. If only one option would be presented, the user will be taken directly to the appropriate form instead of being prompted to choose a form. The `fields` key can configure the behavior of custom fields on specific task subtypes. For example: ``` { ... "fields": { "custom.some-field": { "disabled": true } } ... } ``` Each field supports these options: - `disabled` //Optional bool.// Allows you to disable fields on certain subtypes. - `name` //Optional string.// Custom name of this field for the subtype. The `mutations` key allows you to control the behavior of the "Change Subtype" action above the comment area. By default, this action allows users to change the task subtype into any other subtype. If you'd prefer to make it more difficult to change subtypes or offer only a subset of subtypes, you can specify the list of subtypes that "Change Subtypes" offers. For example, if you have several similar subtypes and want to allow tasks to be converted between them but not easily converted to other types, you can make the "Change Subtypes" control show only these options like this: ``` { ... "mutations": ["bug", "issue", "defect"] ... } ``` If you specify an empty list, the "Change Subtypes" action will be completely hidden. This mutation list is advisory and only configures the UI. Tasks may still be converted across subtypes freely by using the Bulk Editor or API. EOTEXT , $subtype_default_key)); $priorities_description = $this->deformat(pht(<<.// List of unique keywords which identify this priority, like "high" or "low". Each priority must have at least one keyword and two priorities may not share the same keyword. - `short` //Optional string.// Alternate shorter name, used in UIs where there is less space available. - `color` //Optional string.// Color for this priority, like "red" or "blue". - `disabled` //Optional bool.// Set to true to prevent users from choosing this priority when creating or editing tasks. Existing tasks will not be affected, and can be batch edited to a different priority or left to eventually die out. You can choose the default priority for newly created tasks with "maniphest.default-priority". EOTEXT )); $fields_description = $this->deformat(pht(<<newOption('maniphest.custom-field-definitions', 'wild', array()) ->setSummary(pht('Custom Maniphest fields.')) ->setDescription($fields_description) ->addExample($fields_json, pht('Valid setting')), $this->newOption('maniphest.fields', $custom_field_type, $default_fields) ->setCustomData(id(new ManiphestTask())->getCustomFieldBaseClass()) ->setDescription(pht('Select and reorder task fields.')), $this->newOption( 'maniphest.priorities', $priority_type, $priority_defaults) ->setSummary(pht('Configure Maniphest priority names.')) ->setDescription($priorities_description), $this->newOption('maniphest.statuses', $status_type, $status_defaults) ->setSummary(pht('Configure Maniphest task statuses.')) ->setDescription($status_description) ->addExample($status_example, pht('Minimal Valid Config')), $this->newOption('maniphest.default-priority', 'int', 90) ->setSummary(pht('Default task priority for create flows.')) ->setDescription( pht( 'Choose a default priority for newly created tasks. You can '. 'review and adjust available priorities by using the '. '%s configuration option. The default value (`90`) '. 'corresponds to the default "Needs Triage" priority.', 'maniphest.priorities')), $this->newOption('maniphest.points', $points_type, array()) ->setSummary(pht('Configure point values for tasks.')) ->setDescription($points_description) ->addExample($points_json_1, pht('Points Config')) ->addExample($points_json_2, pht('Hours Config')), $this->newOption('maniphest.subtypes', $subtype_type, $subtype_default) ->setSummary(pht('Define task subtypes.')) ->setDescription($subtype_description) ->addExample($subtype_example, pht('Simple Subtypes')), ); } } diff --git a/src/applications/meta/controller/PhabricatorApplicationUninstallController.php b/src/applications/meta/controller/PhabricatorApplicationUninstallController.php index 09f3e92ee5..e89976c42c 100644 --- a/src/applications/meta/controller/PhabricatorApplicationUninstallController.php +++ b/src/applications/meta/controller/PhabricatorApplicationUninstallController.php @@ -1,124 +1,123 @@ getViewer(); $user = $request->getUser(); $action = $request->getURIData('action'); $application_name = $request->getURIData('application'); $application = id(new PhabricatorApplicationQuery()) ->setViewer($viewer) ->withClasses(array($application_name)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$application) { return new Aphront404Response(); } $view_uri = $this->getApplicationURI('view/'.$application_name); $prototypes_enabled = PhabricatorEnv::getEnvConfig( 'phabricator.show-prototypes'); $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addCancelButton($view_uri); if ($application->isPrototype() && !$prototypes_enabled) { $dialog ->setTitle(pht('Prototypes Not Enabled')) ->appendChild( pht( 'To manage prototypes, enable them by setting %s in your '. - 'Phabricator configuration.', + 'configuration.', phutil_tag('tt', array(), 'phabricator.show-prototypes'))); return id(new AphrontDialogResponse())->setDialog($dialog); } if ($request->isDialogFormPost()) { $xactions = array(); $template = $application->getApplicationTransactionTemplate(); $xactions[] = id(clone $template) ->setTransactionType( PhabricatorApplicationUninstallTransaction::TRANSACTIONTYPE) ->setNewValue($action); $editor = id(new PhabricatorApplicationEditor()) ->setActor($user) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); try { $editor->applyTransactions($application, $xactions); return id(new AphrontRedirectResponse())->setURI($view_uri); } catch (PhabricatorApplicationTransactionValidationException $ex) { $validation_exception = $ex; } return $this->newDialog() ->setTitle(pht('Validation Failed')) ->setValidationException($validation_exception) ->addCancelButton($view_uri); } if ($action == 'install') { if ($application->canUninstall()) { $dialog ->setTitle(pht('Confirmation')) ->appendChild( pht( 'Install %s application?', $application->getName())) ->addSubmitButton(pht('Install')); } else { $dialog ->setTitle(pht('Information')) ->appendChild(pht('You cannot install an installed application.')); } } else { if ($application->canUninstall()) { $dialog->setTitle(pht('Really Uninstall Application?')); if ($application instanceof PhabricatorHomeApplication) { $dialog ->appendParagraph( pht( 'Are you absolutely certain you want to uninstall the Home '. 'application?')) ->appendParagraph( pht( 'This is very unusual and will leave you without any '. - 'content on the Phabricator home page. You should only '. - 'do this if you are certain you know what you are doing.')) - ->addSubmitButton(pht('Completely Break Phabricator')); + 'content on the home page. You should only do this if you '. + 'are certain you know what you are doing.')) + ->addSubmitButton(pht('Completely Break Everything')); } else { $dialog ->appendParagraph( pht( 'Really uninstall the %s application?', $application->getName())) ->addSubmitButton(pht('Uninstall')); } } else { $dialog ->setTitle(pht('Information')) ->appendChild( pht( - 'This application cannot be uninstalled, '. - 'because it is required for Phabricator to work.')); + 'This application is required and cannot be uninstalled.')); } } return id(new AphrontDialogResponse())->setDialog($dialog); } } diff --git a/src/applications/metamta/adapter/PhabricatorMailTestAdapter.php b/src/applications/metamta/adapter/PhabricatorMailTestAdapter.php index a6258a8874..311dd78b99 100644 --- a/src/applications/metamta/adapter/PhabricatorMailTestAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailTestAdapter.php @@ -1,161 +1,161 @@ supportsMessageID = $support; return $this; } public function setFailPermanently($fail) { $this->failPermanently = true; return $this; } public function setFailTemporarily($fail) { $this->failTemporarily = true; return $this; } public function getSupportedMessageTypes() { return array( PhabricatorMailEmailMessage::MESSAGETYPE, PhabricatorMailSMSMessage::MESSAGETYPE, ); } protected function validateOptions(array $options) { PhutilTypeSpec::checkMap($options, array()); } public function newDefaultOptions() { return array(); } public function supportsMessageIDHeader() { return $this->supportsMessageID; } public function getGuts() { return $this->guts; } public function sendMessage(PhabricatorMailExternalMessage $message) { if ($this->failPermanently) { throw new PhabricatorMetaMTAPermanentFailureException( pht('Unit Test (Permanent)')); } if ($this->failTemporarily) { throw new Exception( pht('Unit Test (Temporary)')); } switch ($message->getMessageType()) { case PhabricatorMailEmailMessage::MESSAGETYPE: $guts = $this->newEmailGuts($message); break; case PhabricatorMailSMSMessage::MESSAGETYPE: $guts = $this->newSMSGuts($message); break; } $guts['did-send'] = true; $this->guts = $guts; } public function getBody() { return idx($this->guts, 'body'); } public function getHTMLBody() { return idx($this->guts, 'html-body'); } private function newEmailGuts(PhabricatorMailExternalMessage $message) { $guts = array(); $from = $message->getFromAddress(); $guts['from'] = (string)$from; $reply_to = $message->getReplyToAddress(); if ($reply_to) { $guts['reply-to'] = (string)$reply_to; } $to_addresses = $message->getToAddresses(); $to = array(); foreach ($to_addresses as $address) { $to[] = (string)$address; } $guts['tos'] = $to; $cc_addresses = $message->getCCAddresses(); $cc = array(); foreach ($cc_addresses as $address) { $cc[] = (string)$address; } $guts['ccs'] = $cc; $subject = $message->getSubject(); if (strlen($subject)) { $guts['subject'] = $subject; } $headers = $message->getHeaders(); $header_list = array(); foreach ($headers as $header) { $header_list[] = array( $header->getName(), $header->getValue(), ); } $guts['headers'] = $header_list; $text_body = $message->getTextBody(); - if (strlen($text_body)) { + if (phutil_nonempty_string($text_body)) { $guts['body'] = $text_body; } $html_body = $message->getHTMLBody(); - if (strlen($html_body)) { + if (phutil_nonempty_string($html_body)) { $guts['html-body'] = $html_body; } $attachments = $message->getAttachments(); $file_list = array(); foreach ($attachments as $attachment) { $file_list[] = array( 'data' => $attachment->getData(), 'filename' => $attachment->getFilename(), 'mimetype' => $attachment->getMimeType(), ); } $guts['attachments'] = $file_list; return $guts; } private function newSMSGuts(PhabricatorMailExternalMessage $message) { $guts = array(); $guts['to'] = $message->getToNumber(); $guts['body'] = $message->getTextBody(); return $guts; } } diff --git a/src/applications/metamta/constants/MetaMTAReceivedMailStatus.php b/src/applications/metamta/constants/MetaMTAReceivedMailStatus.php index faacdc2cfc..871d47f97f 100644 --- a/src/applications/metamta/constants/MetaMTAReceivedMailStatus.php +++ b/src/applications/metamta/constants/MetaMTAReceivedMailStatus.php @@ -1,42 +1,42 @@ pht('Duplicate Message'), - self::STATUS_FROM_PHABRICATOR => pht('Phabricator Mail'), + self::STATUS_FROM_PHABRICATOR => pht('Mail From Self'), self::STATUS_NO_RECEIVERS => pht('No Receivers'), self::STATUS_UNKNOWN_SENDER => pht('Unknown Sender'), self::STATUS_DISABLED_SENDER => pht('Disabled Sender'), self::STATUS_NO_PUBLIC_MAIL => pht('No Public Mail'), self::STATUS_USER_MISMATCH => pht('User Mismatch'), self::STATUS_POLICY_PROBLEM => pht('Policy Error'), self::STATUS_NO_SUCH_OBJECT => pht('No Such Object'), self::STATUS_HASH_MISMATCH => pht('Bad Address'), self::STATUS_UNHANDLED_EXCEPTION => pht('Unhandled Exception'), self::STATUS_EMPTY => pht('Empty Mail'), self::STATUS_EMPTY_IGNORED => pht('Ignored Empty Mail'), self::STATUS_RESERVED => pht('Reserved Recipient'), ); return idx($map, $status, pht('Processing Exception')); } } diff --git a/src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php b/src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php index d7d31ba254..8029294581 100644 --- a/src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php +++ b/src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php @@ -1,450 +1,450 @@ getViewer(); $mail = id(new PhabricatorMetaMTAMailQuery()) ->setViewer($viewer) ->withIDs(array($request->getURIData('id'))) ->executeOne(); if (!$mail) { return new Aphront404Response(); } if ($mail->hasSensitiveContent()) { $title = pht('Content Redacted'); } else { $title = $mail->getSubject(); } $header = id(new PHUIHeaderView()) ->setHeader($title) ->setUser($viewer) ->setPolicyObject($mail) ->setHeaderIcon('fa-envelope'); $status = $mail->getStatus(); $name = PhabricatorMailOutboundStatus::getStatusName($status); $icon = PhabricatorMailOutboundStatus::getStatusIcon($status); $color = PhabricatorMailOutboundStatus::getStatusColor($status); $header->setStatus($icon, $color, $name); if ($mail->getMustEncrypt()) { Javelin::initBehavior('phabricator-tooltips'); $header->addTag( id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setColor('blue') ->setName(pht('Must Encrypt')) ->setIcon('fa-shield blue') ->addSigil('has-tooltip') ->setMetadata( array( 'tip' => pht( 'Message content can only be transmitted over secure '. 'channels.'), ))); } $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb(pht('Mail %d', $mail->getID())) ->setBorder(true); $tab_group = id(new PHUITabGroupView()) ->addTab( id(new PHUITabView()) ->setName(pht('Message')) ->setKey('message') ->appendChild($this->buildMessageProperties($mail))) ->addTab( id(new PHUITabView()) ->setName(pht('Headers')) ->setKey('headers') ->appendChild($this->buildHeaderProperties($mail))) ->addTab( id(new PHUITabView()) ->setName(pht('Delivery')) ->setKey('delivery') ->appendChild($this->buildDeliveryProperties($mail))) ->addTab( id(new PHUITabView()) ->setName(pht('Metadata')) ->setKey('metadata') ->appendChild($this->buildMetadataProperties($mail))); $header_view = id(new PHUIHeaderView()) ->setHeader(pht('Mail')); $object_phid = $mail->getRelatedPHID(); if ($object_phid) { $handles = $viewer->loadHandles(array($object_phid)); $handle = $handles[$object_phid]; if ($handle->isComplete() && $handle->getURI()) { $view_button = id(new PHUIButtonView()) ->setTag('a') ->setText(pht('View Object')) ->setIcon('fa-chevron-right') ->setHref($handle->getURI()); $header_view->addActionLink($view_button); } } $object_box = id(new PHUIObjectBoxView()) ->setHeader($header_view) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->addTabGroup($tab_group); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter($object_box); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->setPageObjectPHIDs(array($mail->getPHID())) ->appendChild($view); } private function buildMessageProperties(PhabricatorMetaMTAMail $mail) { $viewer = $this->getViewer(); $properties = id(new PHUIPropertyListView()) ->setUser($viewer) ->setObject($mail); if ($mail->getFrom()) { $from_str = $viewer->renderHandle($mail->getFrom()); } else { $from_str = pht('Sent by Phabricator'); } $properties->addProperty( pht('From'), $from_str); if ($mail->getToPHIDs()) { $to_list = $viewer->renderHandleList($mail->getToPHIDs()); } else { $to_list = pht('None'); } $properties->addProperty( pht('To'), $to_list); if ($mail->getCcPHIDs()) { $cc_list = $viewer->renderHandleList($mail->getCcPHIDs()); } else { $cc_list = pht('None'); } $properties->addProperty( pht('Cc'), $cc_list); $properties->addProperty( pht('Sent'), phabricator_datetime($mail->getDateCreated(), $viewer)); $properties->addSectionHeader( pht('Message'), PHUIPropertyListView::ICON_SUMMARY); if ($mail->hasSensitiveContent()) { $body = phutil_tag( 'em', array(), pht( 'The content of this mail is sensitive and it can not be '. 'viewed from the web UI.')); } else { $body = phutil_tag( 'div', array( 'style' => 'white-space: pre-wrap', ), $mail->getBody()); } $properties->addTextContent($body); $file_phids = $mail->getAttachmentFilePHIDs(); if ($file_phids) { $properties->addProperty( pht('Attached Files'), $viewer->loadHandles($file_phids)->renderList()); } return $properties; } private function buildHeaderProperties(PhabricatorMetaMTAMail $mail) { $viewer = $this->getViewer(); $properties = id(new PHUIPropertyListView()) ->setUser($viewer) ->setStacked(true); $headers = $mail->getDeliveredHeaders(); if (!$headers) { $headers = array(); } // Sort headers by name. $headers = isort($headers, 0); foreach ($headers as $header) { list($key, $value) = $header; $properties->addProperty($key, $value); } $encrypt_phids = $mail->getMustEncryptReasons(); if ($encrypt_phids) { $properties->addProperty( pht('Must Encrypt'), $viewer->loadHandles($encrypt_phids) ->renderList()); } return $properties; } private function buildDeliveryProperties(PhabricatorMetaMTAMail $mail) { $viewer = $this->getViewer(); $properties = id(new PHUIPropertyListView()) ->setUser($viewer); $actors = $mail->getDeliveredActors(); $reasons = null; if (!$actors) { if ($mail->getStatus() == PhabricatorMailOutboundStatus::STATUS_QUEUE) { $delivery = $this->renderEmptyMessage( pht( 'This message has not been delivered yet, so delivery information '. 'is not available.')); } else { $delivery = $this->renderEmptyMessage( pht( 'This is an older message that predates recording delivery '. 'information, so none is available.')); } } else { $actor = idx($actors, $viewer->getPHID()); if (!$actor) { $delivery = phutil_tag( 'em', array(), pht('This message was not delivered to you.')); } else { $deliverable = $actor['deliverable']; if ($deliverable) { $delivery = pht('Delivered'); } else { $delivery = pht('Voided'); } $reasons = id(new PHUIStatusListView()); $reason_codes = $actor['reasons']; if (!$reason_codes) { $reason_codes = array( PhabricatorMetaMTAActor::REASON_NONE, ); } $icon_yes = 'fa-check green'; $icon_no = 'fa-times red'; foreach ($reason_codes as $reason) { $target = phutil_tag( 'strong', array(), PhabricatorMetaMTAActor::getReasonName($reason)); if (PhabricatorMetaMTAActor::isDeliveryReason($reason)) { $icon = $icon_yes; } else { $icon = $icon_no; } $item = id(new PHUIStatusItemView()) ->setIcon($icon) ->setTarget($target) ->setNote(PhabricatorMetaMTAActor::getReasonDescription($reason)); $reasons->addItem($item); } } } $properties->addProperty(pht('Delivery'), $delivery); if ($reasons) { $properties->addProperty(pht('Reasons'), $reasons); $properties->addProperty( null, $this->renderEmptyMessage( pht( 'Delivery reasons are listed from weakest to strongest.'))); } $properties->addSectionHeader( pht('Routing Rules'), 'fa-paper-plane-o'); $map = $mail->getDeliveredRoutingMap(); $routing_detail = null; if ($map === null) { if ($mail->getStatus() == PhabricatorMailOutboundStatus::STATUS_QUEUE) { $routing_result = $this->renderEmptyMessage( pht( 'This message has not been sent yet, so routing rules have '. 'not been computed.')); } else { $routing_result = $this->renderEmptyMessage( pht( 'This is an older message which predates routing rules.')); } } else { $rule = idx($map, $viewer->getPHID()); if ($rule === null) { $rule = idx($map, 'default'); } if ($rule === null) { $routing_result = $this->renderEmptyMessage( pht( 'No routing rules applied when delivering this message to you.')); } else { $rule_const = $rule['rule']; $reason_phid = $rule['reason']; switch ($rule_const) { case PhabricatorMailRoutingRule::ROUTE_AS_NOTIFICATION: $routing_result = pht( 'This message was routed as a notification because it '. 'matched %s.', $viewer->renderHandle($reason_phid)->render()); break; case PhabricatorMailRoutingRule::ROUTE_AS_MAIL: $routing_result = pht( 'This message was routed as an email because it matched %s.', $viewer->renderHandle($reason_phid)->render()); break; default: $routing_result = pht('Unknown routing rule "%s".', $rule_const); break; } } $routing_rules = $mail->getDeliveredRoutingRules(); if ($routing_rules) { $rules = array(); foreach ($routing_rules as $rule) { $phids = idx($rule, 'phids'); if ($phids === null) { $rules[] = $rule; } else if (in_array($viewer->getPHID(), $phids)) { $rules[] = $rule; } } // Reorder rules by strength. foreach ($rules as $key => $rule) { $const = $rule['routingRule']; $phids = $rule['phids']; if ($phids === null) { $type = 'A'; } else { $type = 'B'; } $rules[$key]['strength'] = sprintf( '~%s%08d', $type, PhabricatorMailRoutingRule::getRuleStrength($const)); } $rules = isort($rules, 'strength'); $routing_detail = id(new PHUIStatusListView()); foreach ($rules as $rule) { $const = $rule['routingRule']; $phids = $rule['phids']; $name = PhabricatorMailRoutingRule::getRuleName($const); $icon = PhabricatorMailRoutingRule::getRuleIcon($const); $color = PhabricatorMailRoutingRule::getRuleColor($const); if ($phids === null) { $kind = pht('Global'); } else { $kind = pht('Personal'); } $target = array($kind, ': ', $name); $target = phutil_tag('strong', array(), $target); $item = id(new PHUIStatusItemView()) ->setTarget($target) ->setNote($viewer->renderHandle($rule['reasonPHID'])) ->setIcon($icon, $color); $routing_detail->addItem($item); } } } $properties->addProperty(pht('Effective Rule'), $routing_result); if ($routing_detail !== null) { $properties->addProperty(pht('All Matching Rules'), $routing_detail); $properties->addProperty( null, $this->renderEmptyMessage( pht( 'Matching rules are listed from weakest to strongest.'))); } return $properties; } private function buildMetadataProperties(PhabricatorMetaMTAMail $mail) { $viewer = $this->getViewer(); $properties = id(new PHUIPropertyListView()) ->setUser($viewer); $properties->addProperty(pht('Message PHID'), $mail->getPHID()); $details = $mail->getMessage(); if (!strlen($details)) { $details = phutil_tag('em', array(), pht('None')); } $properties->addProperty(pht('Status Details'), $details); $actor_phid = $mail->getActorPHID(); if ($actor_phid) { $actor_str = $viewer->renderHandle($actor_phid); } else { - $actor_str = pht('Generated by Phabricator'); + $actor_str = pht('Generated by Server'); } $properties->addProperty(pht('Actor'), $actor_str); $related_phid = $mail->getRelatedPHID(); if ($related_phid) { $related = $viewer->renderHandle($mail->getRelatedPHID()); } else { $related = phutil_tag('em', array(), pht('None')); } $properties->addProperty(pht('Related Object'), $related); return $properties; } private function renderEmptyMessage($message) { return phutil_tag('em', array(), $message); } } diff --git a/src/applications/metamta/engine/PhabricatorMailEmailEngine.php b/src/applications/metamta/engine/PhabricatorMailEmailEngine.php index ef7b92a7d3..6c9cf1b356 100644 --- a/src/applications/metamta/engine/PhabricatorMailEmailEngine.php +++ b/src/applications/metamta/engine/PhabricatorMailEmailEngine.php @@ -1,648 +1,654 @@ getMailer(); $mail = $this->getMail(); $message = new PhabricatorMailEmailMessage(); $from_address = $this->newFromEmailAddress(); $message->setFromAddress($from_address); $reply_address = $this->newReplyToEmailAddress(); if ($reply_address) { $message->setReplyToAddress($reply_address); } $to_addresses = $this->newToEmailAddresses(); $cc_addresses = $this->newCCEmailAddresses(); if (!$to_addresses && !$cc_addresses) { $mail->setMessage( pht( 'Message has no valid recipients: all To/CC are disabled, '. 'invalid, or configured not to receive this mail.')); return null; } // If this email describes a mail processing error, we rate limit outbound // messages to each individual address. This prevents messes where // something is stuck in a loop or dumps a ton of messages on us suddenly. if ($mail->getIsErrorEmail()) { $all_recipients = array(); foreach ($to_addresses as $to_address) { $all_recipients[] = $to_address->getAddress(); } foreach ($cc_addresses as $cc_address) { $all_recipients[] = $cc_address->getAddress(); } if ($this->shouldRateLimitMail($all_recipients)) { $mail->setMessage( pht( 'This is an error email, but one or more recipients have '. 'exceeded the error email rate limit. Declining to deliver '. 'message.')); return null; } } // 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 (!$to_addresses) { $void_address = $this->newVoidEmailAddress(); $to_addresses = array($void_address); } $to_addresses = $this->getUniqueEmailAddresses($to_addresses); $cc_addresses = $this->getUniqueEmailAddresses( $cc_addresses, $to_addresses); $message->setToAddresses($to_addresses); $message->setCCAddresses($cc_addresses); $attachments = $this->newEmailAttachments(); $message->setAttachments($attachments); $subject = $this->newEmailSubject(); $message->setSubject($subject); $headers = $this->newEmailHeaders(); foreach ($this->newEmailThreadingHeaders($mailer) as $threading_header) { $headers[] = $threading_header; } $stamps = $mail->getMailStamps(); if ($stamps) { $headers[] = $this->newEmailHeader( 'X-Phabricator-Stamps', implode(' ', $stamps)); } $must_encrypt = $mail->getMustEncrypt(); $raw_body = $mail->getBody(); $body = $raw_body; if ($must_encrypt) { $parts = array(); $encrypt_uri = $mail->getMustEncryptURI(); if (!strlen($encrypt_uri)) { $encrypt_phid = $mail->getRelatedPHID(); if ($encrypt_phid) { $encrypt_uri = urisprintf( '/object/%s/', $encrypt_phid); } } if (strlen($encrypt_uri)) { $parts[] = pht( 'This secure message is notifying you of a change to this object:'); $parts[] = PhabricatorEnv::getProductionURI($encrypt_uri); } $parts[] = pht( 'The content for this message can only be transmitted over a '. 'secure channel. To view the message content, follow this '. 'link:'); $parts[] = PhabricatorEnv::getProductionURI($mail->getURI()); $body = implode("\n\n", $parts); } else { $body = $raw_body; } $body_limit = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); + + $body = phutil_string_cast($body); if (strlen($body) > $body_limit) { $body = id(new PhutilUTF8StringTruncator()) ->setMaximumBytes($body_limit) ->truncateString($body); $body .= "\n"; $body .= pht('(This email was truncated at %d bytes.)', $body_limit); } $message->setTextBody($body); $body_limit -= strlen($body); // If we sent a different message body than we were asked to, record // what we actually sent to make debugging and diagnostics easier. if ($body !== $raw_body) { $mail->setDeliveredBody($body); } if ($must_encrypt) { $send_html = false; } else { $send_html = $this->shouldSendHTML(); } if ($send_html) { $html_body = $mail->getHTMLBody(); - if (strlen($html_body)) { + if (phutil_nonempty_string($html_body)) { // NOTE: We just drop the entire HTML body if it won't fit. Safely // truncating HTML is hard, and we already have the text body to fall // back to. if (strlen($html_body) <= $body_limit) { $message->setHTMLBody($html_body); $body_limit -= strlen($html_body); } } } // Pass the headers to the mailer, then save the state so we can show // them in the web UI. If the mail must be encrypted, we remove headers // which are not on a strict whitelist to avoid disclosing information. $filtered_headers = $this->filterHeaders($headers, $must_encrypt); $message->setHeaders($filtered_headers); $mail->setUnfilteredHeaders($headers); $mail->setDeliveredHeaders($headers); if (PhabricatorEnv::getEnvConfig('phabricator.silent')) { $mail->setMessage( pht( - 'Phabricator is running in silent mode. See `%s` '. + 'This software is running in silent mode. See `%s` '. 'in the configuration to change this setting.', 'phabricator.silent')); return null; } return $message; } /* -( Message Components )------------------------------------------------- */ private function newFromEmailAddress() { $from_address = $this->newDefaultEmailAddress(); $mail = $this->getMail(); // If the mail content must be encrypted, always disguise the sender. $must_encrypt = $mail->getMustEncrypt(); if ($must_encrypt) { return $from_address; } // If we have a raw "From" address, use that. $raw_from = $mail->getRawFrom(); if ($raw_from) { list($from_email, $from_name) = $raw_from; return $this->newEmailAddress($from_email, $from_name); } // Otherwise, use as much of the information for any sending entity as // we can. $from_phid = $mail->getFrom(); $actor = $this->getActor($from_phid); if ($actor) { $actor_email = $actor->getEmailAddress(); $actor_name = $actor->getName(); } else { $actor_email = null; $actor_name = null; } $send_as_user = PhabricatorEnv::getEnvConfig('metamta.can-send-as-user'); if ($send_as_user) { if ($actor_email !== null) { $from_address->setAddress($actor_email); } } if ($actor_name !== null) { $from_address->setDisplayName($actor_name); } return $from_address; } private function newReplyToEmailAddress() { $mail = $this->getMail(); $reply_raw = $mail->getReplyTo(); - if (!strlen($reply_raw)) { + if (!phutil_nonempty_string($reply_raw)) { return null; } $reply_address = new PhutilEmailAddress($reply_raw); // If we have a sending object, change the display name. $from_phid = $mail->getFrom(); $actor = $this->getActor($from_phid); if ($actor) { $reply_address->setDisplayName($actor->getName()); } // If we don't have a display name, fill in a default. if (!strlen($reply_address->getDisplayName())) { - $reply_address->setDisplayName(pht('Phabricator')); + $reply_address->setDisplayName(PlatformSymbols::getPlatformServerName()); } return $reply_address; } private function newToEmailAddresses() { $mail = $this->getMail(); $phids = $mail->getToPHIDs(); $addresses = $this->newEmailAddressesFromActorPHIDs($phids); foreach ($mail->getRawToAddresses() as $raw_address) { $addresses[] = new PhutilEmailAddress($raw_address); } return $addresses; } private function newCCEmailAddresses() { $mail = $this->getMail(); $phids = $mail->getCcPHIDs(); return $this->newEmailAddressesFromActorPHIDs($phids); } private function newEmailAddressesFromActorPHIDs(array $phids) { $mail = $this->getMail(); $phids = $mail->expandRecipients($phids); $addresses = array(); foreach ($phids as $phid) { $actor = $this->getActor($phid); if (!$actor) { continue; } if (!$actor->isDeliverable()) { continue; } $addresses[] = new PhutilEmailAddress($actor->getEmailAddress()); } return $addresses; } private function newEmailSubject() { $mail = $this->getMail(); $is_threaded = (bool)$mail->getThreadID(); $must_encrypt = $mail->getMustEncrypt(); $subject = array(); if ($is_threaded) { if ($this->shouldAddRePrefix()) { $subject[] = 'Re:'; } } - $subject[] = trim($mail->getSubjectPrefix()); + $subject_prefix = $mail->getSubjectPrefix(); + $subject_prefix = phutil_string_cast($subject_prefix); + $subject_prefix = trim($subject_prefix); + + $subject[] = $subject_prefix; // If mail content must be encrypted, we replace the subject with // a generic one. if ($must_encrypt) { $encrypt_subject = $mail->getMustEncryptSubject(); if (!strlen($encrypt_subject)) { $encrypt_subject = pht('Object Updated'); } $subject[] = $encrypt_subject; } else { $vary_prefix = $mail->getVarySubjectPrefix(); - if (strlen($vary_prefix)) { + if (phutil_nonempty_string($vary_prefix)) { if ($this->shouldVarySubject()) { $subject[] = $vary_prefix; } } $subject[] = $mail->getSubject(); } foreach ($subject as $key => $part) { - if (!strlen($part)) { + if (!phutil_nonempty_string($part)) { unset($subject[$key]); } } $subject = implode(' ', $subject); return $subject; } private function newEmailHeaders() { $mail = $this->getMail(); $headers = array(); $headers[] = $this->newEmailHeader( 'X-Phabricator-Sent-This-Message', 'Yes'); $headers[] = $this->newEmailHeader( 'X-Mail-Transport-Agent', 'MetaMTA'); // Some clients respect this to suppress OOF and other auto-responses. $headers[] = $this->newEmailHeader( 'X-Auto-Response-Suppress', 'All'); $mailtags = $mail->getMailTags(); if ($mailtags) { $tag_header = array(); foreach ($mailtags as $mailtag) { $tag_header[] = '<'.$mailtag.'>'; } $tag_header = implode(', ', $tag_header); $headers[] = $this->newEmailHeader( 'X-Phabricator-Mail-Tags', $tag_header); } $value = $mail->getHeaders(); 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); $headers[] = $this->newEmailHeader($header_key, $header_value); } $is_bulk = $mail->getIsBulk(); if ($is_bulk) { $headers[] = $this->newEmailHeader('Precedence', 'bulk'); } if ($mail->getMustEncrypt()) { $headers[] = $this->newEmailHeader('X-Phabricator-Must-Encrypt', 'Yes'); } $related_phid = $mail->getRelatedPHID(); if ($related_phid) { $headers[] = $this->newEmailHeader('Thread-Topic', $related_phid); } $headers[] = $this->newEmailHeader( 'X-Phabricator-Mail-ID', $mail->getID()); $unique = Filesystem::readRandomCharacters(16); $headers[] = $this->newEmailHeader( 'X-Phabricator-Send-Attempt', $unique); return $headers; } private function newEmailThreadingHeaders() { $mailer = $this->getMailer(); $mail = $this->getMail(); $headers = array(); $thread_id = $mail->getThreadID(); - if (!strlen($thread_id)) { + if (!phutil_nonempty_string($thread_id)) { return $headers; } $is_first = $mail->getIsFirstMessage(); // 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 = $this->newMailDomain(); $thread_id = '<'.$thread_id.'@'.$domain.'>'; if ($is_first && $mailer->supportsMessageIDHeader()) { $headers[] = $this->newEmailHeader('Message-ID', $thread_id); } else { $in_reply_to = $thread_id; $references = array($thread_id); $parent_id = $mail->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); $headers[] = $this->newEmailHeader('In-Reply-To', $in_reply_to); $headers[] = $this->newEmailHeader('References', $references); } $thread_index = $this->generateThreadIndex($thread_id, $is_first); $headers[] = $this->newEmailHeader('Thread-Index', $thread_index); return $headers; } private function newEmailAttachments() { $mail = $this->getMail(); // If the mail content must be encrypted, don't add attachments. $must_encrypt = $mail->getMustEncrypt(); if ($must_encrypt) { return array(); } return $mail->getAttachments(); } /* -( Preferences )-------------------------------------------------------- */ private function shouldAddRePrefix() { $preferences = $this->getPreferences(); $value = $preferences->getSettingValue( PhabricatorEmailRePrefixSetting::SETTINGKEY); return ($value == PhabricatorEmailRePrefixSetting::VALUE_RE_PREFIX); } private function shouldVarySubject() { $preferences = $this->getPreferences(); $value = $preferences->getSettingValue( PhabricatorEmailVarySubjectsSetting::SETTINGKEY); return ($value == PhabricatorEmailVarySubjectsSetting::VALUE_VARY_SUBJECTS); } private function shouldSendHTML() { $preferences = $this->getPreferences(); $value = $preferences->getSettingValue( PhabricatorEmailFormatSetting::SETTINGKEY); return ($value == PhabricatorEmailFormatSetting::VALUE_HTML_EMAIL); } /* -( Utilities )---------------------------------------------------------- */ private function newEmailHeader($name, $value) { return id(new PhabricatorMailHeader()) ->setName($name) ->setValue($value); } private function newEmailAddress($address, $name = null) { $object = id(new PhutilEmailAddress()) ->setAddress($address); if (strlen($name)) { $object->setDisplayName($name); } return $object; } public function newDefaultEmailAddress() { $raw_address = PhabricatorEnv::getEnvConfig('metamta.default-address'); if (!strlen($raw_address)) { $domain = $this->newMailDomain(); $raw_address = "noreply@{$domain}"; } $address = new PhutilEmailAddress($raw_address); - if (!strlen($address->getDisplayName())) { - $address->setDisplayName(pht('Phabricator')); + if (!phutil_nonempty_string($address->getDisplayName())) { + $address->setDisplayName(PlatformSymbols::getPlatformServerName()); } return $address; } public function newVoidEmailAddress() { return $this->newDefaultEmailAddress(); } private function newMailDomain() { $domain = PhabricatorEnv::getEnvConfig('metamta.reply-handler-domain'); if (strlen($domain)) { return $domain; } $install_uri = PhabricatorEnv::getURI('/'); $install_uri = new PhutilURI($install_uri); return $install_uri->getDomain(); } private function filterHeaders(array $headers, $must_encrypt) { assert_instances_of($headers, 'PhabricatorMailHeader'); if (!$must_encrypt) { return $headers; } $whitelist = array( 'In-Reply-To', 'Message-ID', 'Precedence', 'References', 'Thread-Index', 'Thread-Topic', 'X-Mail-Transport-Agent', 'X-Auto-Response-Suppress', 'X-Phabricator-Sent-This-Message', 'X-Phabricator-Must-Encrypt', 'X-Phabricator-Mail-ID', 'X-Phabricator-Send-Attempt', ); // NOTE: The major header we want to drop is "X-Phabricator-Mail-Tags". // This header contains a significant amount of meaningful information // about the object. $whitelist_map = array(); foreach ($whitelist as $term) { $whitelist_map[phutil_utf8_strtolower($term)] = true; } foreach ($headers as $key => $header) { $name = $header->getName(); $name = phutil_utf8_strtolower($name); if (!isset($whitelist_map[$name])) { unset($headers[$key]); } } return $headers; } private function getUniqueEmailAddresses( array $addresses, array $exclude = array()) { assert_instances_of($addresses, 'PhutilEmailAddress'); assert_instances_of($exclude, 'PhutilEmailAddress'); $seen = array(); foreach ($exclude as $address) { $seen[$address->getAddress()] = true; } foreach ($addresses as $key => $address) { $raw_address = $address->getAddress(); if (isset($seen[$raw_address])) { unset($addresses[$key]); continue; } $seen[$raw_address] = true; } return array_values($addresses); } 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); } 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/management/PhabricatorMailManagementListInboundWorkflow.php b/src/applications/metamta/management/PhabricatorMailManagementListInboundWorkflow.php index 02ed82a6b0..c19923026d 100644 --- a/src/applications/metamta/management/PhabricatorMailManagementListInboundWorkflow.php +++ b/src/applications/metamta/management/PhabricatorMailManagementListInboundWorkflow.php @@ -1,71 +1,71 @@ setName('list-inbound') - ->setSynopsis(pht('List inbound messages received by Phabricator.')) + ->setSynopsis(pht('List inbound messages.')) ->setExamples( '**list-inbound**') ->setArguments( array( array( 'name' => 'limit', 'param' => 'N', 'default' => 100, 'help' => pht( 'Show a specific number of messages (default 100).'), ), )); } public function execute(PhutilArgumentParser $args) { $console = PhutilConsole::getConsole(); $viewer = $this->getViewer(); $mails = id(new PhabricatorMetaMTAReceivedMail())->loadAllWhere( '1 = 1 ORDER BY id DESC LIMIT %d', $args->getArg('limit')); if (!$mails) { $console->writeErr("%s\n", pht('No received mail.')); return 0; } $phids = array_merge( mpull($mails, 'getRelatedPHID'), mpull($mails, 'getAuthorPHID')); $handles = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs($phids) ->execute(); $table = id(new PhutilConsoleTable()) ->setShowHeader(false) ->addColumn('id', array('title' => pht('ID'))) ->addColumn('author', array('title' => pht('Author'))) ->addColumn('phid', array('title' => pht('Related PHID'))) ->addColumn('subject', array('title' => pht('Subject'))); foreach (array_reverse($mails) as $mail) { $table->addRow(array( 'id' => $mail->getID(), 'author' => $mail->getAuthorPHID() ? $handles[$mail->getAuthorPHID()]->getName() : '-', 'phid' => $mail->getRelatedPHID() ? $handles[$mail->getRelatedPHID()]->getName() : '-', 'subject' => $mail->getSubject() ? $mail->getSubject() : pht('(No subject.)'), )); } $table->draw(); return 0; } } diff --git a/src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php b/src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php index 30939dd436..77c363a45b 100644 --- a/src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php +++ b/src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php @@ -1,60 +1,60 @@ setName('list-outbound') - ->setSynopsis(pht('List outbound messages sent by Phabricator.')) + ->setSynopsis(pht('List outbound messages.')) ->setExamples('**list-outbound**') ->setArguments( array( array( 'name' => 'limit', 'param' => 'N', 'default' => 100, 'help' => pht( 'Show a specific number of messages (default 100).'), ), )); } public function execute(PhutilArgumentParser $args) { $console = PhutilConsole::getConsole(); $viewer = $this->getViewer(); $mails = id(new PhabricatorMetaMTAMail())->loadAllWhere( '1 = 1 ORDER BY id DESC LIMIT %d', $args->getArg('limit')); if (!$mails) { $console->writeErr("%s\n", pht('No sent mail.')); return 0; } $table = id(new PhutilConsoleTable()) ->setShowHeader(false) ->addColumn('id', array('title' => pht('ID'))) ->addColumn('encrypt', array('title' => pht('#'))) ->addColumn('status', array('title' => pht('Status'))) ->addColumn('type', array('title' => pht('Type'))) ->addColumn('subject', array('title' => pht('Subject'))); foreach (array_reverse($mails) as $mail) { $status = $mail->getStatus(); $table->addRow(array( 'id' => $mail->getID(), 'encrypt' => ($mail->getMustEncrypt() ? '#' : ' '), 'status' => PhabricatorMailOutboundStatus::getStatusName($status), 'type' => $mail->getMessageType(), 'subject' => $mail->getSubject(), )); } $table->draw(); return 0; } } diff --git a/src/applications/metamta/parser/PhabricatorMetaMTAEmailBodyParser.php b/src/applications/metamta/parser/PhabricatorMetaMTAEmailBodyParser.php index 9b266a3ae7..f20e626573 100644 --- a/src/applications/metamta/parser/PhabricatorMetaMTAEmailBodyParser.php +++ b/src/applications/metamta/parser/PhabricatorMetaMTAEmailBodyParser.php @@ -1,167 +1,168 @@ 'please, take this task I took; it's hard', * 'commands' => array( * array('assign', 'alincoln'), * ), * ) * * @param string Raw mail text body. * @return dict Parsed body. */ public function parseBody($body) { $body = $this->stripTextBody($body); $commands = array(); $lines = phutil_split_lines($body, $retain_endings = true); // We'll match commands at the beginning and end of the mail, but not // in the middle of the mail body. list($top_commands, $lines) = $this->stripCommands($lines); list($end_commands, $lines) = $this->stripCommands(array_reverse($lines)); $lines = array_reverse($lines); $commands = array_merge($top_commands, array_reverse($end_commands)); $lines = rtrim(implode('', $lines)); return array( 'body' => $lines, 'commands' => $commands, ); } private function stripCommands(array $lines) { $saw_command = false; $commands = array(); foreach ($lines as $key => $line) { if (!strlen(trim($line)) && $saw_command) { unset($lines[$key]); continue; } $matches = null; if (!preg_match('/^\s*!(\w+.*$)/', $line, $matches)) { break; } $arg_str = $matches[1]; $argv = preg_split('/[,\s]+/', trim($arg_str)); $commands[] = $argv; unset($lines[$key]); $saw_command = true; } return array($commands, $lines); } public function stripTextBody($body) { return trim($this->stripSignature($this->stripQuotedText($body))); } private function stripQuotedText($body) { + $body = phutil_string_cast($body); // Look for "On , wrote:". This may be split across multiple // lines. We need to be careful not to remove all of a message like this: // // On which day do you want to meet? // // On , wrote: // > Let's set up a meeting. $start = null; $lines = phutil_split_lines($body); foreach ($lines as $key => $line) { if (preg_match('/^\s*>?\s*On\b/', $line)) { $start = $key; } if ($start !== null) { if (preg_match('/\bwrote:/', $line)) { $lines = array_slice($lines, 0, $start); $body = implode('', $lines); break; } } } // Outlook english $body = preg_replace( '/^\s*(> )?-----Original Message-----.*?/imsU', '', $body); // Outlook danish $body = preg_replace( '/^\s*(> )?-----Oprindelig Meddelelse-----.*?/imsU', '', $body); // See example in T3217. $body = preg_replace( '/^________________________________________\s+From:.*?/imsU', '', $body); // French GMail quoted text. See T8199. $body = preg_replace( '/^\s*\d{4}-\d{2}-\d{2} \d+:\d+ GMT.*:.*?/imsU', '', $body); return rtrim($body); } private function stripSignature($body) { // Quasi-"standard" delimiter, for lols see: // https://bugzilla.mozilla.org/show_bug.cgi?id=58406 $body = preg_replace( '/^-- +$.*/sm', '', $body); // Mailbox seems to make an attempt to comply with the "standard" but // omits the leading newline and uses an em dash. This may or may not have // the trailing space, but it's unique enough that there's no real ambiguity // in detecting it. $body = preg_replace( "/\s*\xE2\x80\x94\s*\nSent from Mailbox\s*\z/su", '', $body); // HTC Mail application (mobile) $body = preg_replace( '/^\s*^Sent from my HTC smartphone.*/sm', '', $body); // Apple iPhone $body = preg_replace( '/^\s*^Sent from my iPhone\s*$.*/sm', '', $body); return rtrim($body); } } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php index c620bea30a..d3289bbc69 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php @@ -1,592 +1,593 @@ array( 'headers' => self::SERIALIZATION_JSON, 'bodies' => self::SERIALIZATION_JSON, 'attachments' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'relatedPHID' => 'phid?', 'authorPHID' => 'phid?', 'message' => 'text?', 'messageIDHash' => 'bytes12', 'status' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( 'relatedPHID' => array( 'columns' => array('relatedPHID'), ), 'authorPHID' => array( 'columns' => array('authorPHID'), ), 'key_messageIDHash' => array( 'columns' => array('messageIDHash'), ), 'key_created' => array( 'columns' => array('dateCreated'), ), ), ) + 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 newTargetAddresses() { $raw_addresses = array(); foreach ($this->getToAddresses() as $raw_address) { $raw_addresses[] = $raw_address; } foreach ($this->getCCAddresses() as $raw_address) { $raw_addresses[] = $raw_address; } $raw_addresses = array_unique($raw_addresses); $addresses = array(); foreach ($raw_addresses as $raw_address) { $addresses[] = new PhutilEmailAddress($raw_address); } return $addresses; } public function loadAllRecipientPHIDs() { $addresses = $this->newTargetAddresses(); // See T13317. Don't allow reserved addresses (like "noreply@...") to // match user PHIDs. foreach ($addresses as $key => $address) { if (PhabricatorMailUtil::isReservedAddress($address)) { unset($addresses[$key]); } } if (!$addresses) { return array(); } $address_strings = array(); foreach ($addresses as $address) { $address_strings[] = phutil_string_cast($address->getAddress()); } // See T13317. If a verified email address is in the "To" or "Cc" line, // we'll count the user who owns that address as a recipient. // We require the address be verified because we'll trigger behavior (like // adding subscribers) based on the recipient list, and don't want to add // Alice as a subscriber if she adds an unverified "internal-bounces@" // address to her account and this address gets caught in the crossfire. // In the best case this is confusing; in the worst case it could // some day give her access to objects she can't see. $recipients = id(new PhabricatorUserEmail()) ->loadAllWhere( 'address IN (%Ls) AND isVerified = 1', $address_strings); $recipient_phids = mpull($recipients, 'getUserPHID'); return $recipient_phids; } public function processReceivedMail() { $viewer = $this->getViewer(); $sender = null; try { $this->dropMailFromPhabricator(); $this->dropMailAlreadyReceived(); $this->dropEmptyMail(); $sender = $this->loadSender(); if ($sender) { $this->setAuthorPHID($sender->getPHID()); // If we've identified the sender, mark them as the author of any // attached files. We do this before we validate them (below), since // they still authored these files even if their account is not allowed // to interact via email. $attachments = $this->getAttachments(); if ($attachments) { $files = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs($attachments) ->execute(); foreach ($files as $file) { $file->setAuthorPHID($sender->getPHID())->save(); } } $this->validateSender($sender); } $receivers = id(new PhutilClassMapQuery()) ->setAncestorClass('PhabricatorMailReceiver') ->setFilterMethod('isEnabled') ->execute(); $reserved_recipient = null; $targets = $this->newTargetAddresses(); foreach ($targets as $key => $target) { // Never accept any reserved address as a mail target. This prevents // security issues around "hostmaster@" and bad behavior with // "noreply@". if (PhabricatorMailUtil::isReservedAddress($target)) { if (!$reserved_recipient) { $reserved_recipient = $target; } unset($targets[$key]); continue; } // See T13234. Don't process mail if a user has attached this address // to their account. if (PhabricatorMailUtil::isUserAddress($target)) { unset($targets[$key]); continue; } } $any_accepted = false; $receiver_exception = null; foreach ($receivers as $receiver) { $receiver = id(clone $receiver) ->setViewer($viewer); if ($sender) { $receiver->setSender($sender); } foreach ($targets as $target) { try { if (!$receiver->canAcceptMail($this, $target)) { continue; } $any_accepted = true; $receiver->receiveMail($this, $target); } catch (Exception $ex) { // If receivers raise exceptions, we'll keep the first one in hope // that it points at a root cause. if (!$receiver_exception) { $receiver_exception = $ex; } } } } if ($receiver_exception) { throw $receiver_exception; } if (!$any_accepted) { if ($reserved_recipient) { // If nothing accepted the mail, we normally raise an error to help // users who mistakenly send mail to "barges@" instead of "bugs@". // However, if the recipient list included a reserved recipient, we // don't bounce the mail with an error. // The intent here is that if a user does a "Reply All" and includes // "From: noreply@phabricator" in the receipient list, we just want // to drop the mail rather than send them an unhelpful bounce message. throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_RESERVED, pht( 'No application handled this mail. This mail was sent to a '. 'reserved recipient ("%s") so bounces are suppressed.', (string)$reserved_recipient)); } else if (!$sender) { // NOTE: Currently, we'll always drop this mail (since it's headed to // an unverified recipient). See T12237. These details are still // useful because they'll appear in the mail logs and Mail web UI. throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER, pht( 'This email was sent from an email address ("%s") that is not '. - 'associated with a Phabricator account. To interact with '. - 'Phabricator via email, add this address to your account.', + 'associated with a registered user account. To interact via '. + 'email, add this address to your account.', (string)$this->newFromAddress())); } else { throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_NO_RECEIVERS, pht( - 'Phabricator can not process this mail because no application '. + 'This mail can not be processed 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.)')); + 'is correct.')); } } } 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_RESERVED: // This probably is the sender's fault, but it's likely an accident // that we received the mail at all. break; case MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED: // This error is explicitly ignored. break; default: $this->sendExceptionMail($ex, $sender); break; } $this ->setStatus($ex->getStatusCode()) ->setMessage($ex->getMessage()) ->save(); return $this; } catch (Exception $ex) { $this->sendExceptionMail($ex, $sender); $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); + + if (phutil_nonempty_string($addresses)) { + 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 '%s' header to avoid loops.", 'X-Phabricator-Sent-This-Message')); } /** * 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); } private function dropEmptyMail() { $body = $this->getCleanTextBody(); $attachments = $this->getAttachments(); if (strlen($body) || $attachments) { return; } // Only send an error email if the user is talking to just Phabricator. // We can assume if there is only one "To" address it is a Phabricator // address since this code is running and everything. $is_direct_mail = (count($this->getToAddresses()) == 1) && (count($this->getCCAddresses()) == 0); if ($is_direct_mail) { $status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY; } else { $status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED; } throw new PhabricatorMetaMTAReceivedMailProcessingException( $status_code, pht( 'Your message does not contain any body text or attachments, so '. - 'Phabricator can not do anything useful with it. Make sure comment '. + 'this server can not do anything useful with it. Make sure comment '. 'text appears at the top of your message: quoted replies, inline '. 'text, and signatures are discarded and ignored.')); } private function sendExceptionMail( Exception $ex, PhabricatorUser $viewer = null) { // If we've failed to identify a legitimate sender, we don't send them // an error message back. We want to avoid sending mail to unverified // addresses. See T12491. if (!$viewer) { 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. // On the MimeMailParser pathway, we arrive here with a list value for // headers that appeared multiple times in the original mail. Be // accommodating until header handling gets straightened out. $headers = array(); foreach ($this->headers as $key => $values) { if (!is_array($values)) { $values = array($values); } foreach ($values as $value) { $headers[] = pht('%s: %s', $key, $value); } } $headers = implode("\n", $headers); $body = pht(<<getRawTextBody(), $headers); $mail = id(new PhabricatorMetaMTAMail()) ->setIsErrorEmail(true) ->setSubject($title) ->addTos(array($viewer->getPHID())) ->setBody($body) ->saveAndSend(); } public function newContentSource() { return PhabricatorContentSource::newForSource( PhabricatorEmailContentSource::SOURCECONST, array( 'id' => $this->getID(), )); } public function newFromAddress() { $raw_from = $this->getHeader('From'); if (strlen($raw_from)) { return new PhutilEmailAddress($raw_from); } return null; } private function getViewer() { return PhabricatorUser::getOmnipotentUser(); } /** * Identify the sender's user account for a piece of received mail. * * Note that this method does not validate that the sender is who they say * they are, just that they've presented some credential which corresponds * to a recognizable user. */ private function loadSender() { $viewer = $this->getViewer(); // Try to identify the user based on their "From" address. $from_address = $this->newFromAddress(); if ($from_address) { $user = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withEmails(array($from_address->getAddress())) ->executeOne(); if ($user) { return $user; } } return null; } private function validateSender(PhabricatorUser $sender) { $failure_reason = null; if ($sender->getIsDisabled()) { $failure_reason = pht( 'Your account ("%s") is disabled, so you can not interact with '. - 'Phabricator over email.', + 'over email.', $sender->getUsername()); } else if ($sender->getIsStandardUser()) { if (!$sender->getIsApproved()) { $failure_reason = pht( 'Your account ("%s") has not been approved yet. You can not '. - 'interact with Phabricator over email until your account is '. - 'approved.', + 'interact over email until your account is approved.', $sender->getUsername()); } else if (PhabricatorUserEmail::isEmailVerificationRequired() && !$sender->getIsEmailVerified()) { $failure_reason = pht( 'You have not verified the email address for your account ("%s"). '. - 'You must verify your email address before you can interact '. - 'with Phabricator over email.', + 'You must verify your email address before you can interact over '. + 'email.', $sender->getUsername()); } } if ($failure_reason) { throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_DISABLED_SENDER, $failure_reason); } } } diff --git a/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php b/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php index 7462aaf558..e66dd8c61b 100644 --- a/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php +++ b/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php @@ -1,422 +1,422 @@ true, ); } public function testMailSendFailures() { $user = $this->generateNewTestUser(); $phid = $user->getPHID(); // Normally, the send should succeed. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailTestAdapter(); $mail->sendWithMailers(array($mailer)); $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_SENT, $mail->getStatus()); // When the mailer fails temporarily, the mail should remain queued. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailTestAdapter(); $mailer->setFailTemporarily(true); try { $mail->sendWithMailers(array($mailer)); } catch (Exception $ex) { // Ignore. } $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_QUEUE, $mail->getStatus()); // When the mailer fails permanently, the mail should be failed. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailTestAdapter(); $mailer->setFailPermanently(true); try { $mail->sendWithMailers(array($mailer)); } catch (Exception $ex) { // Ignore. } $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_FAIL, $mail->getStatus()); } public function testRecipients() { $user = $this->generateNewTestUser(); $phid = $user->getPHID(); $mailer = new PhabricatorMailTestAdapter(); $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"To" is a recipient.')); // Test that the "No Self Mail" and "No Mail" preferences work correctly. $mail->setFrom($phid); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"From" does not exclude recipients by default.')); $user = $this->writeSetting( $user, PhabricatorEmailSelfActionsSetting::SETTINGKEY, true); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('"From" excludes recipients with no-self-mail set.')); $user = $this->writeSetting( $user, PhabricatorEmailSelfActionsSetting::SETTINGKEY, null); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"From" does not exclude recipients by default.')); $user = $this->writeSetting( $user, PhabricatorEmailNotificationsSetting::SETTINGKEY, true); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('"From" excludes recipients with no-mail set.')); $mail->setForceDelivery(true); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"From" includes no-mail recipients when forced.')); $mail->setForceDelivery(false); $user = $this->writeSetting( $user, PhabricatorEmailNotificationsSetting::SETTINGKEY, null); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"From" does not exclude recipients by default.')); // Test that explicit exclusion works correctly. $mail->setExcludeMailRecipientPHIDs(array($phid)); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('Explicit exclude excludes recipients.')); $mail->setExcludeMailRecipientPHIDs(array()); // Test that mail tag preferences exclude recipients. $user = $this->writeSetting( $user, PhabricatorEmailTagsSetting::SETTINGKEY, array( 'test-tag' => false, )); $mail->setMailTags(array('test-tag')); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('Tag preference excludes recipients.')); $user = $this->writeSetting( $user, PhabricatorEmailTagsSetting::SETTINGKEY, null); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), 'Recipients restored after tag preference removed.'); $email = id(new PhabricatorUserEmail())->loadOneWhere( 'userPHID = %s AND isPrimary = 1', $phid); $email->setIsVerified(0)->save(); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('Mail not sent to unverified address.')); $email->setIsVerified(1)->save(); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('Mail sent to verified address.')); } 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) { $user = $this->generateNewTestUser(); $phid = $user->getPHID(); $mailer = new PhabricatorMailTestAdapter(); $mailer->setSupportsMessageID($supports_message_id); $thread_id = 'somethread-12345'; $mail = id(new PhabricatorMetaMTAMail()) ->setThreadID($thread_id, $is_first_mail) ->addTos(array($phid)) ->sendWithMailers(array($mailer)); $guts = $mailer->getGuts(); $headers = idx($guts, 'headers', array()); $dict = array(); foreach ($headers as $header) { list($name, $value) = $header; $dict[$name] = $value; } 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']), pht('Expect Thread-Index header for case %s.', $case)); $this->assertEqual( $expect_message_id, isset($dict['Message-ID']), pht( 'Expectation about existence of Message-ID header for case %s.', $case)); $this->assertEqual( $expect_in_reply_to, isset($dict['In-Reply-To']), pht( 'Expectation about existence of In-Reply-To header for case %s.', $case)); $this->assertEqual( $expect_references, isset($dict['References']), pht( 'Expectation about existence of References header for case %s.', $case)); } private function writeSetting(PhabricatorUser $user, $key, $value) { $preferences = PhabricatorUserPreferences::loadUserPreferences($user); $editor = id(new PhabricatorUserPreferencesEditor()) ->setActor($user) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); $xactions = array(); $xactions[] = $preferences->newTransaction($key, $value); $editor->applyTransactions($preferences, $xactions); return id(new PhabricatorPeopleQuery()) ->setViewer($user) ->withIDs(array($user->getID())) ->executeOne(); } public function testMailerFailover() { $user = $this->generateNewTestUser(); $phid = $user->getPHID(); $status_sent = PhabricatorMailOutboundStatus::STATUS_SENT; $status_queue = PhabricatorMailOutboundStatus::STATUS_QUEUE; $status_fail = PhabricatorMailOutboundStatus::STATUS_FAIL; $mailer1 = id(new PhabricatorMailTestAdapter()) ->setKey('mailer1'); $mailer2 = id(new PhabricatorMailTestAdapter()) ->setKey('mailer2'); $mailers = array( $mailer1, $mailer2, ); // Send mail with both mailers active. The first mailer should be used. $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($phid)) ->sendWithMailers($mailers); $this->assertEqual($status_sent, $mail->getStatus()); $this->assertEqual('mailer1', $mail->getMailerKey()); // If the first mailer fails, the mail should be sent with the second // mailer. Since we transmitted the mail, this doesn't raise an exception. $mailer1->setFailTemporarily(true); $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($phid)) ->sendWithMailers($mailers); $this->assertEqual($status_sent, $mail->getStatus()); $this->assertEqual('mailer2', $mail->getMailerKey()); // If both mailers fail, the mail should remain in queue. $mailer2->setFailTemporarily(true); $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($phid)); $caught = null; try { $mail->sendWithMailers($mailers); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); $this->assertEqual($status_queue, $mail->getStatus()); $this->assertEqual(null, $mail->getMailerKey()); $mailer1->setFailTemporarily(false); $mailer2->setFailTemporarily(false); // If the first mailer fails permanently, the mail should fail even though // the second mailer isn't configured to fail. $mailer1->setFailPermanently(true); $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($phid)); $caught = null; try { $mail->sendWithMailers($mailers); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); $this->assertEqual($status_fail, $mail->getStatus()); $this->assertEqual(null, $mail->getMailerKey()); } public function testMailSizeLimits() { $env = PhabricatorEnv::beginScopedEnv(); $env->overrideEnvConfig('metamta.email-body-limit', 1024 * 512); $user = $this->generateNewTestUser(); $phid = $user->getPHID(); $string_1kb = str_repeat('x', 1024); $html_1kb = str_repeat('y', 1024); $string_1mb = str_repeat('x', 1024 * 1024); $html_1mb = str_repeat('y', 1024 * 1024); // First, send a mail with a small text body and a small HTML body to make // sure the basics work properly. $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($phid)) ->setBody($string_1kb) ->setHTMLBody($html_1kb); $mailer = new PhabricatorMailTestAdapter(); $mail->sendWithMailers(array($mailer)); $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_SENT, $mail->getStatus()); $text_body = $mailer->getBody(); $html_body = $mailer->getHTMLBody(); $this->assertEqual($string_1kb, $text_body); $this->assertEqual($html_1kb, $html_body); // Now, send a mail with a large text body and a large HTML body. We expect // the text body to be truncated and the HTML body to be dropped. $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($phid)) ->setBody($string_1mb) ->setHTMLBody($html_1mb); $mailer = new PhabricatorMailTestAdapter(); $mail->sendWithMailers(array($mailer)); $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_SENT, $mail->getStatus()); $text_body = $mailer->getBody(); $html_body = $mailer->getHTMLBody(); // We expect the body was truncated, because it exceeded the body limit. $this->assertTrue( (strlen($text_body) < strlen($string_1mb)), pht('Text Body Truncated')); // We expect the HTML body was dropped completely after the text body was // truncated. $this->assertTrue( - !strlen($html_body), + !phutil_nonempty_string($html_body), pht('HTML Body Removed')); // Next send a mail with a small text body and a large HTML body. We expect // the text body to be intact and the HTML body to be dropped. $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($phid)) ->setBody($string_1kb) ->setHTMLBody($html_1mb); $mailer = new PhabricatorMailTestAdapter(); $mail->sendWithMailers(array($mailer)); $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_SENT, $mail->getStatus()); $text_body = $mailer->getBody(); $html_body = $mailer->getHTMLBody(); $this->assertEqual($string_1kb, $text_body); - $this->assertTrue(!strlen($html_body)); + $this->assertTrue(!phutil_nonempty_string($html_body)); } } diff --git a/src/applications/metamta/util/PhabricatorMailUtil.php b/src/applications/metamta/util/PhabricatorMailUtil.php index a5fbc7179e..270e9786f3 100644 --- a/src/applications/metamta/util/PhabricatorMailUtil.php +++ b/src/applications/metamta/util/PhabricatorMailUtil.php @@ -1,119 +1,118 @@ getAddress(); $raw_address = phutil_utf8_strtolower($raw_address); $raw_address = trim($raw_address); // If a mailbox prefix is configured and present, strip it off. $prefix_key = 'metamta.single-reply-handler-prefix'; $prefix = PhabricatorEnv::getEnvConfig($prefix_key); - $len = strlen($prefix); - if ($len) { + if (phutil_nonempty_string($prefix)) { $prefix = $prefix.'+'; - $len = $len + 1; + $len = strlen($prefix); if (!strncasecmp($raw_address, $prefix, $len)) { $raw_address = substr($raw_address, $len); } } return id(clone $address) ->setAddress($raw_address); } /** * Determine if two inbound email addresses are effectively identical. * * This method strips and normalizes addresses so that equivalent variations * are correctly detected as identical. For example, these addresses are all * considered to match one another: * * "Abraham Lincoln" * alincoln@example.com * * "Abraham" # With configured prefix. * * @param PhutilEmailAddress Email address. * @param PhutilEmailAddress Another email address. * @return bool True if addresses are effectively the same address. */ public static function matchAddresses( PhutilEmailAddress $u, PhutilEmailAddress $v) { $u = self::normalizeAddress($u); $v = self::normalizeAddress($v); return ($u->getAddress() === $v->getAddress()); } public static function isReservedAddress(PhutilEmailAddress $address) { $address = self::normalizeAddress($address); $local = $address->getLocalPart(); $reserved = array( 'admin', 'administrator', 'hostmaster', 'list', 'list-request', 'majordomo', 'postmaster', 'root', 'ssl-admin', 'ssladmin', 'ssladministrator', 'sslwebmaster', 'sysadmin', 'uucp', 'webmaster', 'noreply', 'no-reply', ); $reserved = array_fuse($reserved); if (isset($reserved[$local])) { return true; } $default_address = id(new PhabricatorMailEmailEngine()) ->newDefaultEmailAddress(); if (self::matchAddresses($address, $default_address)) { return true; } $void_address = id(new PhabricatorMailEmailEngine()) ->newVoidEmailAddress(); if (self::matchAddresses($address, $void_address)) { return true; } return false; } public static function isUserAddress(PhutilEmailAddress $address) { $user_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $address->getAddress()); return (bool)$user_email; } } diff --git a/src/applications/settings/setting/PhabricatorEditorSetting.php b/src/applications/settings/setting/PhabricatorEditorSetting.php index a0f1b43c95..1262a17e4d 100644 --- a/src/applications/settings/setting/PhabricatorEditorSetting.php +++ b/src/applications/settings/setting/PhabricatorEditorSetting.php @@ -1,54 +1,54 @@ setPattern($value) ->validatePattern(); } } diff --git a/src/applications/settings/setting/PhabricatorSelectSetting.php b/src/applications/settings/setting/PhabricatorSelectSetting.php index bccf450454..fb3cb2135f 100644 --- a/src/applications/settings/setting/PhabricatorSelectSetting.php +++ b/src/applications/settings/setting/PhabricatorSelectSetting.php @@ -1,76 +1,79 @@ getSettingKey(); $default_value = $object->getDefaultValue($setting_key); $options = $this->getSelectOptions(); if (isset($options[$default_value])) { $default_label = pht('Default (%s)', $options[$default_value]); } else { $default_label = pht('Default (Unknown, "%s")', $default_value); } if (empty($options[''])) { $options = array( '' => $default_label, ) + $options; } return $this->newEditField($object, new PhabricatorSelectEditField()) ->setOptions($options); } public function assertValidValue($value) { // This is a slightly stricter check than the transaction check. It's // OK for empty string to go through transactions because it gets converted // to null later, but we shouldn't be reading the empty string from // storage. if ($value === null) { return; } if (!strlen($value)) { throw new Exception( pht( 'Empty string is not a valid setting for "%s".', $this->getSettingName())); } $this->validateTransactionValue($value); } final public function validateTransactionValue($value) { + $value = phutil_string_cast($value); if (!strlen($value)) { return; } $options = $this->getSelectOptions(); if (!isset($options[$value])) { throw new Exception( pht( 'Value "%s" is not valid for setting "%s": valid values are %s.', $value, $this->getSettingName(), implode(', ', array_keys($options)))); } return; } public function getTransactionNewValue($value) { + $value = phutil_string_cast($value); + if (!strlen($value)) { return null; } - return (string)$value; + return $value; } } diff --git a/src/infrastructure/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php b/src/infrastructure/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php index db52eebe8e..6ff9b3ba90 100644 --- a/src/infrastructure/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php +++ b/src/infrastructure/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php @@ -1,424 +1,425 @@ configuration = $configuration; } public function __clone() { $this->establishConnection(); } public function openConnection() { $this->requireConnection(); } public function close() { if ($this->lastResult) { $this->lastResult = null; } if ($this->connection) { $this->closeConnection(); $this->connection = null; } } public function escapeColumnName($name) { return '`'.str_replace('`', '``', $name).'`'; } public function escapeMultilineComment($comment) { // These can either terminate a comment, confuse the hell out of the parser, // make MySQL execute the comment as a query, or, in the case of semicolon, // are quasi-dangerous because the semicolon could turn a broken query into // a working query plus an ignored query. static $map = array( '--' => '(DOUBLEDASH)', '*/' => '(STARSLASH)', '//' => '(SLASHSLASH)', '#' => '(HASH)', '!' => '(BANG)', ';' => '(SEMICOLON)', ); $comment = str_replace( array_keys($map), array_values($map), $comment); // For good measure, kill anything else that isn't a nice printable // character. $comment = preg_replace('/[^\x20-\x7F]+/', ' ', $comment); return '/* '.$comment.' */'; } public function escapeStringForLikeClause($value) { + $value = phutil_string_cast($value); $value = addcslashes($value, '\%_'); $value = $this->escapeUTF8String($value); return $value; } protected function getConfiguration($key, $default = null) { return idx($this->configuration, $key, $default); } private function establishConnection() { $host = $this->getConfiguration('host'); $database = $this->getConfiguration('database'); $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall( array( 'type' => 'connect', 'host' => $host, 'database' => $database, )); // If we receive these errors, we'll retry the connection up to the // retry limit. For other errors, we'll fail immediately. $retry_codes = array( // "Connection Timeout" 2002 => true, // "Unable to Connect" 2003 => true, ); $max_retries = max(1, $this->getConfiguration('retries', 3)); for ($attempt = 1; $attempt <= $max_retries; $attempt++) { try { $conn = $this->connect(); $profiler->endServiceCall($call_id, array()); break; } catch (AphrontQueryException $ex) { $code = $ex->getCode(); if (($attempt < $max_retries) && isset($retry_codes[$code])) { $message = pht( 'Retrying database connection to "%s" after connection '. 'failure (attempt %d; "%s"; error #%d): %s', $host, $attempt, get_class($ex), $code, $ex->getMessage()); // See T13403. If we're silenced with the "@" operator, don't log // this connection attempt. This keeps things quiet if we're // running a setup workflow like "bin/config" and expect that the // database credentials will often be incorrect. if (error_reporting()) { phlog($message); } } else { $profiler->endServiceCall($call_id, array()); throw $ex; } } } $this->connection = $conn; } protected function requireConnection() { if (!$this->connection) { if ($this->connectionPool) { $this->connection = array_pop($this->connectionPool); } else { $this->establishConnection(); } } return $this->connection; } protected function beginAsyncConnection() { $connection = $this->requireConnection(); $this->connection = null; return $connection; } protected function endAsyncConnection($connection) { if ($this->connection) { $this->connectionPool[] = $this->connection; } $this->connection = $connection; } public function selectAllResults() { $result = array(); $res = $this->lastResult; if ($res == null) { throw new Exception(pht('No query result to fetch from!')); } while (($row = $this->fetchAssoc($res))) { $result[] = $row; } return $result; } public function executeQuery(PhutilQueryString $query) { $display_query = $query->getMaskedString(); $raw_query = $query->getUnmaskedString(); $this->lastResult = null; $retries = max(1, $this->getConfiguration('retries', 3)); while ($retries--) { try { $this->requireConnection(); $is_write = $this->checkWrite($raw_query); $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall( array( 'type' => 'query', 'config' => $this->configuration, 'query' => $display_query, 'write' => $is_write, )); $result = $this->rawQuery($raw_query); $profiler->endServiceCall($call_id, array()); if ($this->nextError) { $result = null; } if ($result) { $this->lastResult = $result; break; } $this->throwQueryException($this->connection); } catch (AphrontConnectionLostQueryException $ex) { $can_retry = ($retries > 0); if ($this->isInsideTransaction()) { // Zero out the transaction state to prevent a second exception // ("program exited with open transaction") from being thrown, since // we're about to throw a more relevant/useful one instead. $state = $this->getTransactionState(); while ($state->getDepth()) { $state->decreaseDepth(); } $can_retry = false; } if ($this->isHoldingAnyLock()) { $this->forgetAllLocks(); $can_retry = false; } $this->close(); if (!$can_retry) { throw $ex; } } } } public function executeRawQueries(array $raw_queries) { if (!$raw_queries) { return array(); } $is_write = false; foreach ($raw_queries as $key => $raw_query) { $is_write = $is_write || $this->checkWrite($raw_query); $raw_queries[$key] = rtrim($raw_query, "\r\n\t ;"); } $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall( array( 'type' => 'multi-query', 'config' => $this->configuration, 'queries' => $raw_queries, 'write' => $is_write, )); $results = $this->rawQueries($raw_queries); $profiler->endServiceCall($call_id, array()); return $results; } protected function processResult($result) { if (!$result) { try { $this->throwQueryException($this->requireConnection()); } catch (Exception $ex) { return $ex; } } else if (is_bool($result)) { return $this->getAffectedRows(); } $rows = array(); while (($row = $this->fetchAssoc($result))) { $rows[] = $row; } $this->freeResult($result); return $rows; } protected function checkWrite($raw_query) { // NOTE: The opening "(" allows queries in the form of: // // (SELECT ...) UNION (SELECT ...) $is_write = !preg_match('/^[(]*(SELECT|SHOW|EXPLAIN)\s/', $raw_query); if ($is_write) { if ($this->getReadOnly()) { throw new Exception( pht( 'Attempting to issue a write query on a read-only '. 'connection (to database "%s")!', $this->getConfiguration('database'))); } AphrontWriteGuard::willWrite(); return true; } return false; } protected function throwQueryException($connection) { if ($this->nextError) { $errno = $this->nextError; $error = pht('Simulated error.'); $this->nextError = null; } else { $errno = $this->getErrorCode($connection); $error = $this->getErrorDescription($connection); } $this->throwQueryCodeException($errno, $error); } private function throwCommonException($errno, $error) { $message = pht('#%d: %s', $errno, $error); switch ($errno) { case 2013: // Connection Dropped throw new AphrontConnectionLostQueryException($message); case 2006: // Gone Away $more = pht( 'This error may occur if your configured MySQL "wait_timeout" or '. '"max_allowed_packet" values are too small. This may also indicate '. 'that something used the MySQL "KILL " command to kill '. 'the connection running the query.'); throw new AphrontConnectionLostQueryException("{$message}\n\n{$more}"); case 1213: // Deadlock throw new AphrontDeadlockQueryException($message); case 1205: // Lock wait timeout exceeded throw new AphrontLockTimeoutQueryException($message); case 1062: // Duplicate Key // NOTE: In some versions of MySQL we get a key name back here, but // older versions just give us a key index ("key 2") so it's not // portable to parse the key out of the error and attach it to the // exception. throw new AphrontDuplicateKeyQueryException($message); case 1044: // Access denied to database case 1142: // Access denied to table case 1143: // Access denied to column case 1227: // Access denied (e.g., no SUPER for SHOW SLAVE STATUS). // See T13622. Try to help users figure out that this is a GRANT // problem. $more = pht( 'This error usually indicates that you need to "GRANT" the '. 'MySQL user additional permissions. See "GRANT" in the MySQL '. 'manual for help.'); throw new AphrontAccessDeniedQueryException("{$message}\n\n{$more}"); case 1045: // Access denied (auth) throw new AphrontInvalidCredentialsQueryException($message); case 1146: // No such table case 1049: // No such database case 1054: // Unknown column "..." in field list throw new AphrontSchemaQueryException($message); } // TODO: 1064 is syntax error, and quite terrible in production. return null; } protected function throwConnectionException($errno, $error, $user, $host) { $this->throwCommonException($errno, $error); $message = pht( 'Attempt to connect to %s@%s failed with error #%d: %s.', $user, $host, $errno, $error); throw new AphrontConnectionQueryException($message, $errno); } protected function throwQueryCodeException($errno, $error) { $this->throwCommonException($errno, $error); $message = pht( '#%d: %s', $errno, $error); throw new AphrontQueryException($message, $errno); } /** * Force the next query to fail with a simulated error. This should be used * ONLY for unit tests. */ public function simulateErrorOnNextQuery($error) { $this->nextError = $error; return $this; } /** * Check inserts for characters outside of the BMP. Even with the strictest * settings, MySQL will silently truncate data when it encounters these, which * can lead to data loss and security problems. */ protected function validateUTF8String($string) { if (phutil_is_utf8($string)) { return; } throw new AphrontCharacterSetQueryException( pht( 'Attempting to construct a query using a non-utf8 string when '. 'utf8 is expected. Use the `%%B` conversion to escape binary '. 'strings data.')); } }