diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -7,7 +7,7 @@ */ return array( 'names' => array( - 'core.pkg.css' => 'c0818fbb', + 'core.pkg.css' => 'a5ff7d87', 'core.pkg.js' => '14887b3d', 'darkconsole.pkg.js' => 'df001cab', 'differential.pkg.css' => '4a93db37', @@ -37,7 +37,7 @@ 'rsrc/css/aphront/typeahead.css' => 'a989b5b3', 'rsrc/css/application/auth/auth.css' => '1e655982', 'rsrc/css/application/base/main-menu-view.css' => 'aceca0e9', - 'rsrc/css/application/base/notification-menu.css' => '8ae4a008', + 'rsrc/css/application/base/notification-menu.css' => '5e3b5c86', 'rsrc/css/application/base/phabricator-application-launch-view.css' => '8b7e271d', 'rsrc/css/application/base/standard-page-view.css' => '517cdfb1', 'rsrc/css/application/chatlog/chatlog.css' => '852140ff', @@ -730,7 +730,7 @@ 'phabricator-nav-view-css' => '9283c2df', 'phabricator-notification' => '0c6946e7', 'phabricator-notification-css' => 'ef2c9b34', - 'phabricator-notification-menu-css' => '8ae4a008', + 'phabricator-notification-menu-css' => '5e3b5c86', 'phabricator-object-selector-css' => '029a133d', 'phabricator-phtize' => 'd254d646', 'phabricator-prefab' => 'bbae734c', diff --git a/resources/sql/stopwords.txt b/resources/sql/stopwords.txt new file mode 100644 --- /dev/null +++ b/resources/sql/stopwords.txt @@ -0,0 +1,50 @@ +the +be +and +of +a +in +to +have +to +it +I +that +for +you +he +with +on +do +say +this +they +at +but +we +his +from +that +not +by +or +as +what +go +their +can +who +get +if +would +all +my +will +as +up +there +so +its +us +in +on diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1728,6 +1728,7 @@ 'PhabricatorMetaMTAMail' => 'applications/metamta/storage/PhabricatorMetaMTAMail.php', 'PhabricatorMetaMTAMailBody' => 'applications/metamta/view/PhabricatorMetaMTAMailBody.php', 'PhabricatorMetaMTAMailBodyTestCase' => 'applications/metamta/view/__tests__/PhabricatorMetaMTAMailBodyTestCase.php', + 'PhabricatorMetaMTAMailSection' => 'applications/metamta/view/PhabricatorMetaMTAMailSection.php', 'PhabricatorMetaMTAMailTestCase' => 'applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php', 'PhabricatorMetaMTAMailableDatasource' => 'applications/metamta/typeahead/PhabricatorMetaMTAMailableDatasource.php', 'PhabricatorMetaMTAMailgunReceiveController' => 'applications/metamta/controller/PhabricatorMetaMTAMailgunReceiveController.php', diff --git a/src/applications/auth/management/PhabricatorAuthManagementLDAPWorkflow.php b/src/applications/auth/management/PhabricatorAuthManagementLDAPWorkflow.php --- a/src/applications/auth/management/PhabricatorAuthManagementLDAPWorkflow.php +++ b/src/applications/auth/management/PhabricatorAuthManagementLDAPWorkflow.php @@ -15,6 +15,8 @@ $console = PhutilConsole::getConsole(); $console->getServer()->setEnableLog(true); + PhabricatorLDAPAuthProvider::assertLDAPExtensionInstalled(); + $provider = PhabricatorLDAPAuthProvider::getLDAPProvider(); if (!$provider) { $console->writeOut( diff --git a/src/applications/auth/provider/PhabricatorLDAPAuthProvider.php b/src/applications/auth/provider/PhabricatorLDAPAuthProvider.php --- a/src/applications/auth/provider/PhabricatorLDAPAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorLDAPAuthProvider.php @@ -251,12 +251,26 @@ return array($errors, $issues, $values); } + public static function assertLDAPExtensionInstalled() { + if (!function_exists('ldap_bind')) { + throw new Exception( + pht( + 'Before you can set up or use LDAP, you need to install the PHP '. + 'LDAP extension. It is not currently installed, so PHP can not '. + 'talk to LDAP. Usually you can install it with '. + '`yum install php-ldap`, `apt-get install php5-ldap`, or a '. + 'similar package manager command.')); + } + } + public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { + self::assertLDAPExtensionInstalled(); + $labels = $this->getPropertyLabels(); $captions = array( diff --git a/src/applications/config/check/PhabricatorSetupCheckMySQL.php b/src/applications/config/check/PhabricatorSetupCheckMySQL.php --- a/src/applications/config/check/PhabricatorSetupCheckMySQL.php +++ b/src/applications/config/check/PhabricatorSetupCheckMySQL.php @@ -59,6 +59,106 @@ ->setSummary($summary) ->setMessage($message); } + + $stopword_file = queryfx_one($conn_raw, 'SELECT @@ft_stopword_file'); + $stopword_file = $stopword_file['@@ft_stopword_file']; + if ($stopword_file == '(built-in)') { + if (!PhabricatorDefaultSearchEngineSelector::shouldUseElasticSearch()) { + + $root = dirname(phutil_get_library_root('phabricator')); + $stopword_path = $root.'/resources/sql/stopwords.txt'; + $stopword_path = Filesystem::resolvePath($stopword_path); + + $namespace = PhabricatorEnv::getEnvConfig('storage.default-namespace'); + + $summary = pht( + 'MySQL is using a default stopword file, which will prevent '. + 'searching for many common words.'); + + $message = pht( + "Your MySQL instance is using the builtin stopword file for ". + "building search indexes. This can make Phabricator's search ". + "feature less useful.\n\n". + "Stopwords are common words which are not indexed and thus can not ". + "be searched for. The default stopword file has about 500 words, ". + "including various words which you are likely to wish to search ". + "for, such as 'various', 'likely', 'wish', and 'zero'.\n\n". + "To make search more useful, you can use an alternate stopword ". + "file with fewer words. Alternatively, if you aren't concerned ". + "about searching for common words, you can ignore this warning. ". + "If you later plan to configure ElasticSearch, you can also ignore ". + "this warning: this stopword file only affects MySQL fulltext ". + "indexes.\n\n". + "To choose a different stopword file, add this to your %s file ". + "(in the %s section) and then restart %s:\n\n". + "%s\n". + "(You can also use a different file if you prefer. The file ". + "suggested above has about 50 of the most common English words.)\n\n". + "Finally, run this command to rebuild indexes using the new ". + "rules:\n\n". + "%s", + phutil_tag('tt', array(), 'my.cnf'), + phutil_tag('tt', array(), '[mysqld]'), + phutil_tag('tt', array(), 'mysqld'), + phutil_tag('pre', array(), 'ft_stopword_file='.$stopword_path), + phutil_tag( + 'pre', + array(), + "mysql> REPAIR TABLE {$namespace}_search.search_documentfield;")); + + $this->newIssue('mysql.ft_stopword_file') + ->setName(pht('MySQL is Using Default Stopword File')) + ->setSummary($summary) + ->setMessage($message); + } + } + + + $min_len = queryfx_one($conn_raw, 'SELECT @@ft_min_word_len'); + $min_len = $min_len['@@ft_min_word_len']; + if ($min_len == 4) { + if (!PhabricatorDefaultSearchEngineSelector::shouldUseElasticSearch()) { + $namespace = PhabricatorEnv::getEnvConfig('storage.default-namespace'); + + $summary = pht( + 'MySQL is configured to only index words with at least 4 '. + 'characters.'); + + $message = pht( + "Your MySQL instance is configured to use the default minimum word ". + "length when building search indexes, which is 4. This means words ". + "which are only 3 characters long will not be indexed and can not ". + "be searched for.\n\n". + "For example, you will not be able to find search results for words ". + "like 'SMS', 'web', or 'DOS'.\n\n". + "You can change this setting to 3 to allow these words to be ". + "indexed. Alternatively, you can ignore this warning if you are ". + "not concerned about searching for 3-letter words. If you later ". + "plan to configure ElasticSearch, you can also ignore this warning: ". + "only MySQL fulltext search is affected.\n\n". + "To reduce the minimum word length to 3, add this to your %s file ". + "(in the %s section) and then restart %s:\n\n". + "%s\n". + "Finally, run this command to rebuild indexes using the new ". + "rules:\n\n". + "%s", + phutil_tag('tt', array(), 'my.cnf'), + phutil_tag('tt', array(), '[mysqld]'), + phutil_tag('tt', array(), 'mysqld'), + phutil_tag('pre', array(), 'ft_min_word_len=3'), + phutil_tag( + 'pre', + array(), + "mysql> REPAIR TABLE {$namespace}_search.search_documentfield;")); + + $this->newIssue('mysql.ft_min_word_len') + ->setName(pht('MySQL is Using Default Minimum Word Length')) + ->setSummary($summary) + ->setMessage($message); + } + } + + } } diff --git a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php --- a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php +++ b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php @@ -331,7 +331,20 @@ 'in bytes.')) ->setSummary(pht('Global cap for size of generated emails (bytes).')) ->addExample(524288, pht('Truncate at 512KB')) - ->addExample(1048576, pht('Truncate at 1MB')) + ->addExample(1048576, pht('Truncate at 1MB')), + $this->newOption('metamta.html-emails', 'bool', true) + ->setBoolOptions( + array( + pht('Enable HTML emails'), + pht('Plaintext emails only'), + )) + ->setSummary( + pht( + 'Phabricator can send prettier emails when HTML is enabled ')) + ->setDescription( + pht('HTML emails offer prettier emails (colorized diffs, links etc)'. + ', however it may reduce the reliability of reply-by-email')), + ); } diff --git a/src/applications/differential/editor/DifferentialTransactionEditor.php b/src/applications/differential/editor/DifferentialTransactionEditor.php --- a/src/applications/differential/editor/DifferentialTransactionEditor.php +++ b/src/applications/differential/editor/DifferentialTransactionEditor.php @@ -1179,20 +1179,21 @@ $config_attach = PhabricatorEnv::getEnvConfig($config_key_attach); if ($config_inline || $config_attach) { - $patch = $this->renderPatchForMail($diff); - $lines = count(phutil_split_lines($patch)); + $patch_section = $this->renderPatchForMail($diff); + $lines = count(phutil_split_lines($patch_section->getPlaintext())); if ($config_inline && ($lines <= $config_inline)) { $body->addTextSection( pht('CHANGE DETAILS'), - $patch); + $patch_section); } if ($config_attach) { $name = pht('D%s.%s.patch', $object->getID(), $diff->getID()); $mime_type = 'text/x-patch; charset=utf-8'; $body->addAttachment( - new PhabricatorMetaMTAAttachment($patch, $name, $mime_type)); + new PhabricatorMetaMTAAttachment( + $patch_section->getPlaintext(), $name, $mime_type)); } } } @@ -1330,7 +1331,7 @@ $hunk_parser = new DifferentialHunkParser(); } - $result = array(); + $section = new PhabricatorMetaMTAMailSection(); foreach ($inline_groups as $changeset_id => $group) { $changeset = idx($changesets, $changeset_id); if (!$changeset) { @@ -1351,25 +1352,27 @@ $inline_content = $comment->getContent(); if (!$show_context) { - $result[] = "{$file}:{$range} {$inline_content}"; + $section->addFragment("{$file}:{$range} {$inline_content}"); } else { - $result[] = '================'; - $result[] = 'Comment at: '.$file.':'.$range; - $result[] = $hunk_parser->makeContextDiff( + $patch = $hunk_parser->makeContextDiff( $changeset->getHunks(), $comment->getIsNewFile(), $comment->getLineNumber(), $comment->getLineLength(), 1); - $result[] = '----------------'; - $result[] = $inline_content; - $result[] = null; + $section->addFragment('================') + ->addFragment('Comment at: '.$file.':'.$range) + ->addPlaintextFragment($patch) + ->addHTMLFragment($this->renderPatchHTMLForMail($patch)) + ->addFragment('----------------') + ->addFragment($inline_content) + ->addFragment(null); } } } - return implode("\n", $result); + return $section; } private function loadDiff($phid, $need_changesets = false) { @@ -1762,14 +1765,25 @@ return implode("\n", $filenames); } + private function renderPatchHTMLForMail($patch) { + return phutil_tag('pre', + array('style' => 'font-family: monospace;'), $patch); + } + private function renderPatchForMail(DifferentialDiff $diff) { $format = PhabricatorEnv::getEnvConfig('metamta.differential.patch-format'); - return id(new DifferentialRawDiffRenderer()) + $patch = id(new DifferentialRawDiffRenderer()) ->setViewer($this->getActor()) ->setFormat($format) ->setChangesets($diff->getChangesets()) ->buildPatch(); + + $section = new PhabricatorMetaMTAMailSection(); + $section->addHTMLFragment($this->renderPatchHTMLForMail($patch)); + $section->addPlaintextFragment($patch); + + return $section; } } diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php --- a/src/applications/differential/parser/DifferentialChangesetParser.php +++ b/src/applications/differential/parser/DifferentialChangesetParser.php @@ -690,22 +690,6 @@ return false; } - $old = $changeset->getOldProperties(); - $new = $changeset->getNewProperties(); - - if ($old === $new) { - return false; - } - - if ($changeset->getChangeType() == DifferentialChangeType::TYPE_ADD && - $new == array('unix:filemode' => '100644')) { - return false; - } - - if ($changeset->getChangeType() == DifferentialChangeType::TYPE_DELETE && - $old == array('unix:filemode' => '100644')) { - return false; - } return true; } @@ -927,6 +911,9 @@ } } + $renderer->attachOldFile($old); + $renderer->attachNewFile($new); + return $renderer->renderFileChange($old, $new, $id, $vs); case DifferentialChangeType::FILE_DIRECTORY: case DifferentialChangeType::FILE_BINARY: diff --git a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php --- a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php @@ -289,13 +289,23 @@ protected function renderPropertyChangeHeader() { $changeset = $this->getChangeset(); + list($old, $new) = $this->getChangesetProperties($changeset); - $old = $changeset->getOldProperties(); - $new = $changeset->getNewProperties(); + // If we don't have any property changes, don't render this table. + if ($old === $new) { + return null; + } $keys = array_keys($old + $new); sort($keys); + $key_map = array( + 'unix:filemode' => pht('File Mode'), + 'file:dimensions' => pht('Image Dimensions'), + 'file:mimetype' => pht('MIME Type'), + 'file:size' => pht('File Size'), + ); + $rows = array(); foreach ($keys as $key) { $oval = idx($old, $key); @@ -313,8 +323,10 @@ $nval = phutil_escape_html_newlines($nval); } + $readable_key = idx($key_map, $key, $key); + $rows[] = phutil_tag('tr', array(), array( - phutil_tag('th', array(), $key), + phutil_tag('th', array(), $readable_key), phutil_tag('td', array('class' => 'oval'), $oval), phutil_tag('td', array('class' => 'nval'), $nval), )); diff --git a/src/applications/differential/render/DifferentialChangesetRenderer.php b/src/applications/differential/render/DifferentialChangesetRenderer.php --- a/src/applications/differential/render/DifferentialChangesetRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetRenderer.php @@ -30,6 +30,9 @@ private $depths; private $originalCharacterEncoding; + private $oldFile = false; + private $newFile = false; + public function setOriginalCharacterEncoding($original_character_encoding) { $this->originalCharacterEncoding = $original_character_encoding; return $this; @@ -63,6 +66,38 @@ return $this->gaps; } + public function attachOldFile(PhabricatorFile $old = null) { + $this->oldFile = $old; + return $this; + } + + public function getOldFile() { + if ($this->oldFile === false) { + throw new PhabricatorDataNotAttachedException($this); + } + return $this->oldFile; + } + + public function hasOldFile() { + return (bool)$this->oldFile; + } + + public function attachNewFile(PhabricatorFile $new = null) { + $this->newFile = $new; + return $this; + } + + public function getNewFile() { + if ($this->newFile === false) { + throw new PhabricatorDataNotAttachedException($this); + } + return $this->newFile; + } + + public function hasNewFile() { + return (bool)$this->newFile; + } + public function setOriginalNew($original_new) { $this->originalNew = $original_new; return $this; @@ -498,4 +533,43 @@ return $out; } + protected function getChangesetProperties($changeset) { + $old = $changeset->getOldProperties(); + $new = $changeset->getNewProperties(); + + // When adding files, don't show the uninteresting 644 filemode change. + if ($changeset->getChangeType() == DifferentialChangeType::TYPE_ADD && + $new == array('unix:filemode' => '100644')) { + unset($new['unix:filemode']); + } + + // Likewise when removing files. + if ($changeset->getChangeType() == DifferentialChangeType::TYPE_DELETE && + $old == array('unix:filemode' => '100644')) { + unset($old['unix:filemode']); + } + + if ($this->hasOldFile()) { + $file = $this->getOldFile(); + if ($file->getImageWidth()) { + $dimensions = $file->getImageWidth().'x'.$file->getImageHeight(); + $old['file:dimensions'] = $dimensions; + } + $old['file:mimetype'] = $file->getMimeType(); + $old['file:size'] = phutil_format_bytes($file->getByteSize()); + } + + if ($this->hasNewFile()) { + $file = $this->getNewFile(); + if ($file->getImageWidth()) { + $dimensions = $file->getImageWidth().'x'.$file->getImageHeight(); + $new['file:dimensions'] = $dimensions; + } + $new['file:mimetype'] = $file->getMimeType(); + $new['file:size'] = phutil_format_bytes($file->getByteSize()); + } + + return array($old, $new); + } + } diff --git a/src/applications/differential/render/DifferentialChangesetTestRenderer.php b/src/applications/differential/render/DifferentialChangesetTestRenderer.php --- a/src/applications/differential/render/DifferentialChangesetTestRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetTestRenderer.php @@ -23,8 +23,7 @@ protected function renderPropertyChangeHeader() { $changeset = $this->getChangeset(); - $old = $changeset->getOldProperties(); - $new = $changeset->getNewProperties(); + list($old, $new) = $this->getChangesetProperties($changeset); if (!$old && !$new) { return null; diff --git a/src/applications/metamta/adapter/PhabricatorMailImplementationAdapter.php b/src/applications/metamta/adapter/PhabricatorMailImplementationAdapter.php --- a/src/applications/metamta/adapter/PhabricatorMailImplementationAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailImplementationAdapter.php @@ -8,9 +8,9 @@ abstract public function addCCs(array $emails); abstract public function addAttachment($data, $filename, $mimetype); abstract public function addHeader($header_name, $header_value); - abstract public function setBody($body); + abstract public function setBody($plaintext_body); + abstract public function setHTMLBody($html_body); abstract public function setSubject($subject); - abstract public function setIsHTML($is_html); /** * Some mailers, notably Amazon SES, do not support us setting a specific diff --git a/src/applications/metamta/adapter/PhabricatorMailImplementationMailgunAdapter.php b/src/applications/metamta/adapter/PhabricatorMailImplementationMailgunAdapter.php --- a/src/applications/metamta/adapter/PhabricatorMailImplementationMailgunAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailImplementationMailgunAdapter.php @@ -57,13 +57,13 @@ return $this; } - public function setSubject($subject) { - $this->params['subject'] = $subject; + public function setHTMLBody($html_body) { + $this->params['html-body'] = $html_body; return $this; } - public function setIsHTML($is_html) { - $this->params['is-html'] = $is_html; + public function setSubject($subject) { + $this->params['subject'] = $subject; return $this; } @@ -78,11 +78,10 @@ $params['to'] = implode(', ', idx($this->params, 'tos', array())); $params['subject'] = idx($this->params, 'subject'); + $params['text'] = idx($this->params, 'body'); - if (idx($this->params, 'is-html')) { - $params['html'] = idx($this->params, 'body'); - } else { - $params['text'] = idx($this->params, 'body'); + if (idx($this->params, 'html-body')) { + $params['html'] = idx($this->params, 'html-body'); } $from = idx($this->params, 'from'); diff --git a/src/applications/metamta/adapter/PhabricatorMailImplementationPHPMailerAdapter.php b/src/applications/metamta/adapter/PhabricatorMailImplementationPHPMailerAdapter.php --- a/src/applications/metamta/adapter/PhabricatorMailImplementationPHPMailerAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailImplementationPHPMailerAdapter.php @@ -91,17 +91,19 @@ } public function setBody($body) { + $this->mailer->IsHTML(false); $this->mailer->Body = $body; return $this; } - public function setSubject($subject) { - $this->mailer->Subject = $subject; + public function setHTMLBody($html_body) { + $this->mailer->IsHTML(true); + $this->mailer->Body = $html_body; return $this; } - public function setIsHTML($is_html) { - $this->mailer->IsHTML($is_html); + public function setSubject($subject) { + $this->mailer->Subject = $subject; return $this; } diff --git a/src/applications/metamta/adapter/PhabricatorMailImplementationPHPMailerLiteAdapter.php b/src/applications/metamta/adapter/PhabricatorMailImplementationPHPMailerLiteAdapter.php --- a/src/applications/metamta/adapter/PhabricatorMailImplementationPHPMailerLiteAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailImplementationPHPMailerLiteAdapter.php @@ -70,16 +70,24 @@ public function setBody($body) { $this->mailer->Body = $body; + $this->mailer->IsHTML(false); return $this; } - public function setSubject($subject) { - $this->mailer->Subject = $subject; + + /** + * Note: phpmailer-lite does NOT support sending messages with mixed version + * (plaintext and html). So for now lets just use HTML if it's available. + * @param $html + */ + public function setHTMLBody($html_body) { + $this->mailer->Body = $html_body; + $this->mailer->IsHTML(true); return $this; } - public function setIsHTML($is_html) { - $this->mailer->IsHTML($is_html); + public function setSubject($subject) { + $this->mailer->Subject = $subject; return $this; } diff --git a/src/applications/metamta/adapter/PhabricatorMailImplementationSendGridAdapter.php b/src/applications/metamta/adapter/PhabricatorMailImplementationSendGridAdapter.php --- a/src/applications/metamta/adapter/PhabricatorMailImplementationSendGridAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailImplementationSendGridAdapter.php @@ -56,13 +56,14 @@ return $this; } - public function setSubject($subject) { - $this->params['subject'] = $subject; + public function setHTMLBody($body) { + $this->params['html-body'] = $body; return $this; } - public function setIsHTML($is_html) { - $this->params['is-html'] = $is_html; + + public function setSubject($subject) { + $this->params['subject'] = $subject; return $this; } @@ -89,10 +90,10 @@ } $params['subject'] = idx($this->params, 'subject'); - if (idx($this->params, 'is-html')) { - $params['html'] = idx($this->params, 'body'); - } else { - $params['text'] = idx($this->params, 'body'); + $params['text'] = idx($this->params, 'body'); + + if (idx($this->params, 'html-body')) { + $params['html'] = idx($this->params, 'html-body'); } $params['from'] = idx($this->params, 'from'); diff --git a/src/applications/metamta/adapter/PhabricatorMailImplementationTestAdapter.php b/src/applications/metamta/adapter/PhabricatorMailImplementationTestAdapter.php --- a/src/applications/metamta/adapter/PhabricatorMailImplementationTestAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailImplementationTestAdapter.php @@ -64,13 +64,13 @@ return $this; } - public function setSubject($subject) { - $this->guts['subject'] = $subject; + public function setHTMLBody($html_body) { + $this->guts['html-body'] = $html_body; return $this; } - public function setIsHTML($is_html) { - $this->guts['is-html'] = $is_html; + public function setSubject($subject) { + $this->guts['subject'] = $subject; return $this; } diff --git a/src/applications/metamta/management/PhabricatorMailManagementSendTestWorkflow.php b/src/applications/metamta/management/PhabricatorMailManagementSendTestWorkflow.php --- a/src/applications/metamta/management/PhabricatorMailManagementSendTestWorkflow.php +++ b/src/applications/metamta/management/PhabricatorMailManagementSendTestWorkflow.php @@ -105,7 +105,6 @@ $subject = $args->getArg('subject'); $tags = $args->getArg('tag'); $attach = $args->getArg('attach'); - $is_html = $args->getArg('html'); $is_bulk = $args->getArg('bulk'); $console->writeErr("%s\n", pht('Reading message body from stdin...')); @@ -117,10 +116,19 @@ ->addCCs($ccs) ->setSubject($subject) ->setBody($body) - ->setIsHTML($is_html) ->setIsBulk($is_bulk) ->setMailTags($tags); + if ($args->getArg('html')) { + $mail->setBody( + pht('(This is a placeholder plaintext email body for a test message '. + 'sent with --html.)')); + + $mail->setHTMLBody($body); + } else { + $mail->setBody($body); + } + if ($from) { $mail->setFrom($from->getPHID()); } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php --- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php @@ -212,13 +212,13 @@ return $this; } - public function getBody() { - return $this->getParam('body'); + public function setHTMLBody($html) { + $this->setParam('html-body', $html); + return $this; } - public function setIsHTML($html) { - $this->setParam('is-html', $html); - return $this; + public function getBody() { + return $this->getParam('body'); } public function setIsErrorEmail($is_error) { @@ -377,6 +377,32 @@ $add_cc = array(); $add_to = array(); + // Only try to use preferences if everything is multiplexed, so we + // get consistent behavior. + $use_prefs = self::shouldMultiplexAllMail(); + + $prefs = null; + if ($use_prefs) { + + // If multiplexing is enabled, some recipients will be in "Cc" + // rather than "To". We'll move them to "To" later (or supply a + // dummy "To") but need to look for the recipient in either the + // "To" or "Cc" fields here. + $target_phid = head(idx($params, 'to', array())); + if (!$target_phid) { + $target_phid = head(idx($params, 'cc', array())); + } + + if ($target_phid) { + $user = id(new PhabricatorUser())->loadOneWhere( + 'phid = %s', + $target_phid); + if ($user) { + $prefs = $user->loadPreferences(); + } + } + } + foreach ($params as $key => $value) { switch ($key) { case 'from': @@ -444,42 +470,7 @@ $attachment->getMimeType()); } break; - case 'body': - $max = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); - if (strlen($value) > $max) { - $value = phutil_utf8_shorten($value, $max); - $value .= "\n"; - $value .= pht('(This email was truncated at %d bytes.)', $max); - } - $mailer->setBody($value); - break; case 'subject': - // Only try to use preferences if everything is multiplexed, so we - // get consistent behavior. - $use_prefs = self::shouldMultiplexAllMail(); - - $prefs = null; - if ($use_prefs) { - - // If multiplexing is enabled, some recipients will be in "Cc" - // rather than "To". We'll move them to "To" later (or supply a - // dummy "To") but need to look for the recipient in either the - // "To" or "Cc" fields here. - $target_phid = head(idx($params, 'to', array())); - if (!$target_phid) { - $target_phid = head(idx($params, 'cc', array())); - } - - if ($target_phid) { - $user = id(new PhabricatorUser())->loadOneWhere( - 'phid = %s', - $target_phid); - if ($user) { - $prefs = $user->loadPreferences(); - } - } - } - $subject = array(); if ($is_threaded) { @@ -518,11 +509,6 @@ $mailer->setSubject(implode(' ', array_filter($subject))); break; - case 'is-html': - if ($value) { - $mailer->setIsHTML(true); - } - break; case 'is-bulk': if ($value) { if (PhabricatorEnv::getEnvConfig('metamta.precedence-bulk')) { @@ -570,6 +556,26 @@ } } + $body = idx($params, 'body', ''); + $max = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); + if (strlen($body) > $max) { + $body = phutil_utf8_shorten($body, $max); + $body .= "\n"; + $body .= pht('(This email was truncated at %d bytes.)', $max); + } + $mailer->setBody($body); + + $html_emails = PhabricatorEnv::getEnvConfig('metamta.html-emails'); + if ($use_prefs && $prefs) { + $html_emails = $prefs->getPreference( + PhabricatorUserPreferences::PREFERENCE_HTML_EMAILS, + $html_emails); + } + + if ($html_emails && isset($params['html-body'])) { + $mailer->setHTMLBody($params['html-body']); + } + if (!$add_to && !$add_cc) { $this->setStatus(self::STATUS_VOID); $this->setMessage( diff --git a/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php b/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php --- a/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php +++ b/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php @@ -9,6 +9,7 @@ final class PhabricatorMetaMTAMailBody { private $sections = array(); + private $htmlSections = array(); private $attachments = array(); @@ -24,7 +25,25 @@ */ public function addRawSection($text) { if (strlen($text)) { - $this->sections[] = rtrim($text); + $text = rtrim($text); + $this->sections[] = $text; + $this->htmlSections[] = phutil_escape_html_newlines( + phutil_tag('div', array(), $text)); + } + return $this; + } + + public function addRawPlaintextSection($text) { + if (strlen($text)) { + $text = rtrim($text); + $this->sections[] = $text; + } + return $this; + } + + public function addRawHTMLSection($html) { + if (strlen($html)) { + $this->htmlSections[] = $html; } return $this; } @@ -41,11 +60,34 @@ * @return this * @task compose */ - public function addTextSection($header, $text) { + public function addTextSection($header, $section) { + if ($section instanceof PhabricatorMetaMTAMailSection) { + $plaintext = $section->getPlaintext(); + $html = $section->getHTML(); + } else { + $plaintext = $section; + $html = phutil_escape_html_newlines(phutil_tag('div', array(), $section)); + } + + $this->addPlaintextSection($header, $plaintext); + $this->addHTMLSection($header, $html); + return $this; + } + + public function addPlaintextSection($header, $text) { $this->sections[] = $header."\n".$this->indent($text); return $this; } + public function addHTMLSection($header, $html_fragment) { + $this->htmlSections[] = phutil_tag('div', + array( + 'style' => 'font-weight:800;' + ), + $header).$html_fragment; + + return $this; + } /** * Add a Herald section with a rule management URI and a transcript URI. @@ -114,6 +156,9 @@ return implode("\n\n", $this->sections)."\n"; } + public function renderHTML() { + return implode('

', $this->htmlSections).'
'; + } /** * Retrieve attachments. diff --git a/src/applications/metamta/view/PhabricatorMetaMTAMailSection.php b/src/applications/metamta/view/PhabricatorMetaMTAMailSection.php new file mode 100644 --- /dev/null +++ b/src/applications/metamta/view/PhabricatorMetaMTAMailSection.php @@ -0,0 +1,40 @@ +htmlFragments); + } + + public function getPlaintext() { + return implode("\n", $this->plaintextFragments); + } + + public function addHTMLFragment($fragment) { + $this->htmlFragments[] = $fragment; + return $this; + + } + + public function addPlaintextFragment($fragment) { + $this->plaintextFragments[] = $fragment; + return $this; + } + + public function addFragment($fragment) { + $this->plaintextFragments[] = $fragment; + $this->htmlFragments[] = + phutil_escape_html_newlines(phutil_tag('div', array(), $fragment)); + + return $this; + } +} diff --git a/src/applications/search/selector/PhabricatorDefaultSearchEngineSelector.php b/src/applications/search/selector/PhabricatorDefaultSearchEngineSelector.php --- a/src/applications/search/selector/PhabricatorDefaultSearchEngineSelector.php +++ b/src/applications/search/selector/PhabricatorDefaultSearchEngineSelector.php @@ -4,11 +4,16 @@ extends PhabricatorSearchEngineSelector { public function newEngine() { - $elastic_host = PhabricatorEnv::getEnvConfig('search.elastic.host'); - if ($elastic_host) { + if (self::shouldUseElasticSearch()) { + $elastic_host = PhabricatorEnv::getEnvConfig('search.elastic.host'); $elastic_index = PhabricatorEnv::getEnvConfig('search.elastic.namespace'); return new PhabricatorSearchEngineElastic($elastic_host, $elastic_index); } return new PhabricatorSearchEngineMySQL(); } + + public static function shouldUseElasticSearch() { + return (bool)PhabricatorEnv::getEnvConfig('search.elastic.host'); + } + } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelEmailFormat.php b/src/applications/settings/panel/PhabricatorSettingsPanelEmailFormat.php --- a/src/applications/settings/panel/PhabricatorSettingsPanelEmailFormat.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelEmailFormat.php @@ -22,6 +22,7 @@ $pref_re_prefix = PhabricatorUserPreferences::PREFERENCE_RE_PREFIX; $pref_vary = PhabricatorUserPreferences::PREFERENCE_VARY_SUBJECT; + $prefs_html_email = PhabricatorUserPreferences::PREFERENCE_HTML_EMAILS; $errors = array(); if ($request->isFormPost()) { @@ -42,6 +43,14 @@ $pref_vary, $request->getBool($pref_vary)); } + + if ($request->getStr($prefs_html_email) == 'default') { + $preferences->unsetPreference($prefs_html_email); + } else { + $preferences->setPreference( + $prefs_html_email, + $request->getBool($prefs_html_email)); + } } $preferences->save(); @@ -58,6 +67,10 @@ ? pht('Vary') : pht('Do Not Vary'); + $html_emails_default = PhabricatorEnv::getEnvConfig('metamta.html-emails') + ? pht('HTML') + : pht('plaintext'); + $re_prefix_value = $preferences->getPreference($pref_re_prefix); if ($re_prefix_value === null) { $re_prefix_value = 'default'; @@ -76,11 +89,30 @@ : 'false'; } + $html_emails_value = $preferences->getPreference($prefs_html_email); + if ($html_emails_value === null) { + $html_emails_value = 'default'; + } else { + $html_emails_value = $html_emails_value + ? 'true' + : 'false'; + } + $form = new AphrontFormView(); $form ->setUser($user); if (PhabricatorMetaMTAMail::shouldMultiplexAllMail()) { + $html_email_control = id(new AphrontFormSelectControl()) + ->setName($prefs_html_email) + ->setOptions( + array( + 'default' => pht('Use Server Default (%s)', $html_emails_default), + 'true' => pht('HTML emails'), + 'false' => pht('Plaintext emails'), + )) + ->setValue($html_emails_value); + $re_control = id(new AphrontFormSelectControl()) ->setName($pref_re_prefix) ->setOptions( @@ -101,6 +133,9 @@ )) ->setValue($vary_value); } else { + $html_email_control = id(new AphrontFormStaticControl()) + ->setValue('Server Default ('.$html_emails_default.')'); + $re_control = id(new AphrontFormStaticControl()) ->setValue('Server Default ('.$re_prefix_default.')'); @@ -124,6 +159,7 @@ } $form + ->appendChild($html_email_control) ->appendRemarkupInstructions('') ->appendRemarkupInstructions( pht( diff --git a/src/applications/settings/storage/PhabricatorUserPreferences.php b/src/applications/settings/storage/PhabricatorUserPreferences.php --- a/src/applications/settings/storage/PhabricatorUserPreferences.php +++ b/src/applications/settings/storage/PhabricatorUserPreferences.php @@ -15,6 +15,7 @@ const PREFERENCE_NO_MAIL = 'no-mail'; const PREFERENCE_MAILTAGS = 'mailtags'; const PREFERENCE_VARY_SUBJECT = 'vary-subject'; + const PREFERENCE_HTML_EMAILS = 'html-emails'; const PREFERENCE_SEARCHBAR_JUMP = 'searchbar-jump'; const PREFERENCE_SEARCH_SHORTCUT = 'search-shortcut'; diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -1865,7 +1865,8 @@ ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs()) ->setMailTags($mail_tags) ->setIsBulk(true) - ->setBody($body->render()); + ->setBody($body->render()) + ->setHTMLBody($body->renderHTML()); foreach ($body->getAttachments() as $attachment) { $template->addAttachment($attachment); @@ -1886,14 +1887,18 @@ $template->addHeader('X-Herald-Rules', $herald_header); } + if ($object instanceof PhabricatorProjectInterface) { + $this->addMailProjectMetadata($object, $template); + } + if ($this->getParentMessageID()) { $template->setParentMessageID($this->getParentMessageID()); } $mails = $reply_handler->multiplexMail( - $template, - array_select_keys($handles, $email_to), - array_select_keys($handles, $email_cc)); + $template, + array_select_keys($handles, $email_to), + array_select_keys($handles, $email_cc)); foreach ($mails as $mail) { $mail->saveAndSend(); @@ -1905,6 +1910,44 @@ return $template; } + private function addMailProjectMetadata( + PhabricatorLiskDAO $object, + PhabricatorMetaMTAMail $template) { + + $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( + $object->getPHID(), + PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); + + if (!$project_phids) { + return; + } + + // TODO: This viewer isn't quite right. It would be slightly better to use + // the mail recipient, but that's not very easy given the way rendering + // works today. + + $handles = id(new PhabricatorHandleQuery()) + ->setViewer($this->requireActor()) + ->withPHIDs($project_phids) + ->execute(); + + $project_tags = array(); + foreach ($handles as $handle) { + if (!$handle->isComplete()) { + continue; + } + $project_tags[] = '<'.$handle->getObjectName().'>'; + } + + if (!$project_tags) { + return; + } + + $project_tags = implode(', ', $project_tags); + $template->addHeader('X-Phabricator-Projects', $project_tags); + } + + protected function getMailThreadID(PhabricatorLiskDAO $object) { return $object->getPHID(); } @@ -2054,6 +2097,7 @@ array $xactions) { $headers = array(); + $html_headers = array(); $comments = array(); foreach ($xactions as $xaction) { @@ -2064,6 +2108,9 @@ $header = $xaction->getTitleForMail(); if ($header !== null) { $headers[] = $header; + $html_headers[] = phutil_tag('span', array( + 'style' => 'color:'.$xaction->getColor() + ), $header); } $comment = $xaction->getBodyForMail(); @@ -2073,7 +2120,8 @@ } $body = new PhabricatorMetaMTAMailBody(); - $body->addRawSection(implode("\n", $headers)); + $body->addRawPlaintextSection(implode("\n", $headers)); + $body->addRawHTMLSection(implode('
', $html_headers)); foreach ($comments as $comment) { $body->addRawSection($comment); diff --git a/src/infrastructure/daemon/bot/PhabricatorBot.php b/src/infrastructure/daemon/bot/PhabricatorBot.php --- a/src/infrastructure/daemon/bot/PhabricatorBot.php +++ b/src/infrastructure/daemon/bot/PhabricatorBot.php @@ -83,6 +83,8 @@ ->connect(); $this->runLoop(); + + $this->protocolAdapter->disconnect(); } public function getConfig($key, $default = null) { @@ -104,6 +106,7 @@ $handler->runBackgroundTasks(); } } while (!$this->shouldExit()); + } public function writeMessage(PhabricatorBotMessage $message) { diff --git a/src/infrastructure/daemon/bot/adapter/PhabricatorBaseProtocolAdapter.php b/src/infrastructure/daemon/bot/adapter/PhabricatorBaseProtocolAdapter.php --- a/src/infrastructure/daemon/bot/adapter/PhabricatorBaseProtocolAdapter.php +++ b/src/infrastructure/daemon/bot/adapter/PhabricatorBaseProtocolAdapter.php @@ -22,6 +22,13 @@ abstract public function connect(); /** + * Disconnect from the service. + */ + public function disconnect() { + return; + } + + /** * This is the spout for messages coming in from the protocol. * This will be called in the main event loop of the bot daemon * So if if doesn't implement some sort of blocking timeout diff --git a/src/infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php b/src/infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php --- a/src/infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php +++ b/src/infrastructure/daemon/bot/adapter/PhabricatorIRCProtocolAdapter.php @@ -71,8 +71,13 @@ $ok = @stream_select($read, $write, $except, $timeout_sec = 1); if ($ok === false) { - throw new Exception( - 'socket_select() failed: '.socket_strerror(socket_last_error())); + // We may have been interrupted by a signal, like a SIGINT. Try + // selecting again. If the second select works, conclude that the failure + // was most likely because we were signaled. + $ok = @stream_select($read, $write, $except, $timeout_sec = 0); + if ($ok === false) { + throw new Exception('stream_select() failed!'); + } } if ($read) { @@ -102,6 +107,8 @@ $len = fwrite($this->socket, $this->writeBuffer); if ($len === false) { throw new Exception('fwrite() failed!'); + } else if ($len === 0) { + break; } else { $messages[] = id(new PhabricatorBotMessage()) ->setCommand('LOG') @@ -250,8 +257,22 @@ $data); } - public function __destruct() { - $this->write('QUIT Goodbye.'); - fclose($this->socket); + public function disconnect() { + // NOTE: FreeNode doesn't show quit messages if you've recently joined a + // channel, presumably to prevent some kind of abuse. If you're testing + // this, you may need to stay connected to the network for a few minutes + // before it works. If you disconnect too quickly, the server will replace + // your message with a "Client Quit" message. + + $quit = $this->getConfig('quit', pht('Shutting down.')); + $this->write("QUIT :{$quit}"); + + // Flush the write buffer. + while (strlen($this->writeBuffer)) { + $this->getNextMessages(0); + } + + @fclose($this->socket); + $this->socket = null; } } diff --git a/src/infrastructure/storage/lisk/PhabricatorDataNotAttachedException.php b/src/infrastructure/storage/lisk/PhabricatorDataNotAttachedException.php --- a/src/infrastructure/storage/lisk/PhabricatorDataNotAttachedException.php +++ b/src/infrastructure/storage/lisk/PhabricatorDataNotAttachedException.php @@ -2,7 +2,7 @@ final class PhabricatorDataNotAttachedException extends Exception { - public function __construct(PhabricatorLiskDAO $dao) { + public function __construct($object) { $stack = debug_backtrace(); // Shift off `PhabricatorDataNotAttachedException::__construct()`. @@ -19,7 +19,7 @@ } } - $class = get_class($dao); + $class = get_class($object); $message = "Attempting to access attached data on {$class}{$via}, but the data is ". diff --git a/webroot/rsrc/css/application/base/notification-menu.css b/webroot/rsrc/css/application/base/notification-menu.css --- a/webroot/rsrc/css/application/base/notification-menu.css +++ b/webroot/rsrc/css/application/base/notification-menu.css @@ -25,7 +25,7 @@ .device-desktop .phabricator-notification-menu, .device-tablet .phabricator-notification-menu { - position: fixed; + position: absolute; width: 360px; top: 42px; }