diff --git a/conf/default.conf.php b/conf/default.conf.php index a90b9738a8..fcd09e4898 100644 --- a/conf/default.conf.php +++ b/conf/default.conf.php @@ -1,408 +1,419 @@ null, // If you have multiple environments, provide the production environment URI // here so that emails, etc., generated in development/sandbox environments // contain the right links. 'phabricator.production-uri' => null, // Setting this to 'true' will invoke a special setup mode which helps guide // you through setting up Phabricator. 'phabricator.setup' => false, // The default PHID for users who haven't uploaded a profile image. It should // be 50x50px. 'user.default-profile-image-phid' => 'PHID-FILE-4d61229816cfe6f2b2a3', // -- DarkConsole ----------------------------------------------------------- // // DarkConsole is a administrative debugging/profiling tool built into // Phabricator. You can leave it disabled unless you're developing against // Phabricator. // Determines whether or not DarkConsole is available. DarkConsole exposes // some data like queries and stack traces, so you should be careful about // turning it on in production (although users can not normally see it, even // if the deployment configuration enables it). 'darkconsole.enabled' => false, // Always enable DarkConsole, even for logged out users. This potentially // exposes sensitive information to users, so make sure untrusted users can // not access an install running in this mode. You should definitely leave // this off in production. It is only really useful for using DarkConsole // utilties to debug or profile logged-out pages. You must set // 'darkconsole.enabled' to use this option. 'darkconsole.always-on' => false, // Allows you to mask certain configuration values from appearing in the // "Config" tab of DarkConsole. 'darkconsole.config-mask' => array( 'mysql.pass', 'amazon-ses.secret-key', 'recaptcha.private-key', 'phabricator.csrf-key', 'facebook.application-secret', 'github.application-secret', ), // -- MySQL --------------------------------------------------------------- // // The username to use when connecting to MySQL. 'mysql.user' => 'root', // The password to use when connecting to MySQL. 'mysql.pass' => '', // The MySQL server to connect to. If you want to connect to a different // port than the default (which is 3306), specify it in the hostname // (e.g., db.example.com:1234). 'mysql.host' => 'localhost', // -- Email ----------------------------------------------------------------- // // Some Phabricator tools send email notifications, e.g. when Differential // revisions are updated or Maniphest tasks are changed. These options allow // you to configure how email is delivered. // You can test your mail setup by going to "MetaMTA" in the web interface, // clicking "Send New Message", and then composing a message. // Default address to send mail "From". 'metamta.default-address' => 'noreply@example.com', // Domain used to generate Message-IDs. 'metamta.domain' => 'example.com', // When a user takes an action which generates an email notification (like // commenting on a Differential revision), Phabricator can either send that // mail "From" the user's email address (like "alincoln@logcabin.com") or // "From" the 'metamta.default-address' address. The user experience is // generally better if Phabricator uses the user's real address as the "From" // since the messages are easier to organize when they appear in mail clients, // but this will only work if the server is authorized to send email on behalf // of the "From" domain. Practically, this means: // - If you are doing an install for Example Corp and all the users will // have corporate @corp.example.com addresses and any hosts Phabricator // is running on are authorized to send email from corp.example.com, // you can enable this to make the user experience a little better. // - If you are doing an install for an open source project and your // users will be registering via Facebook and using personal email // addresses, you MUST NOT enable this or virtually all of your outgoing // email will vanish into SFP blackholes. // - If your install is anything else, you're much safer leaving this // off since the risk in turning it on is that your outgoing mail will // mostly never arrive. 'metamta.can-send-as-user' => false, // Adapter class to use to transmit mail to the MTA. The default uses // PHPMailerLite, which will invoke "sendmail". This is appropriate // if sendmail actually works on your host, but if you haven't configured mail // it may not be so great. You can also use Amazon SES, by changing this to // 'PhabricatorMailImplementationAmazonSESAdapter', signing up for SES, and // filling in your 'amazon-ses.access-key' and 'amazon-ses.secret-key' below. 'metamta.mail-adapter' => 'PhabricatorMailImplementationPHPMailerLiteAdapter', // When email is sent, try to hand it off to the MTA immediately. This may // be worth disabling if your MTA infrastructure is slow or unreliable. If you // disable this option, you must run the 'metamta_mta.php' daemon or mail // won't be handed off to the MTA. If you're using Amazon SES it can be a // little slugish sometimes so it may be worth disabling this and moving to // the daemon after you've got your install up and running. If you have a // properly configured local MTA it should not be necessary to disable this. 'metamta.send-immediately' => true, // If you're using Amazon SES to send email, provide your AWS access key // and AWS secret key here. To set up Amazon SES with Phabricator, you need // to: // - Make sure 'metamta.mail-adapter' is set to: // "PhabricatorMailImplementationAmazonSESAdapter" // - Make sure 'metamta.can-send-as-user' is false. // - Make sure 'metamta.default-address' is configured to something sensible. // - Make sure 'metamta.default-address' is a validated SES "From" address. 'amazon-ses.access-key' => null, 'amazon-ses.secret-key' => null, // If you're using Sendgrid to send email, provide your access credentials // here. This will use the REST API. You can also use Sendgrid as a normal // SMTP service. 'sendgrid.api-user' => null, 'sendgrid.api-key' => null, // You can configure a reply handler domain so that email sent from Maniphest // will have a special "Reply To" address like "T123+82+af19f@example.com" // that allows recipients to reply by email and interact with tasks. For // instructions on configurating reply handlers, see the article // "Configuring Inbound Email" in the Phabricator documentation. By default, // this is set to 'null' and Phabricator will use a generic 'noreply@' address // or the address of the acting user instead of a special reply handler // address (see 'metamta.default-address'). If you set a domain here, // Phabricator will begin generating private reply handler addresses. See // also 'metamta.maniphest.reply-handler' to further configure behavior. // This key should be set to the domain part after the @, like "example.com". 'metamta.maniphest.reply-handler-domain' => null, // You can follow the instructions in "Configuring Inbound Email" in the // Phabricator documentation and set 'metamta.maniphest.reply-handler-domain' // to support updating Maniphest tasks by email. If you want more advanced // customization than this provides, you can override the reply handler // class with an implementation of your own. This will allow you to do things // like have a single public reply handler or change how private reply // handlers are generated and validated. // This key should be set to a loadable subclass of // PhabricatorMailReplyHandler (and possibly of ManiphestReplyHandler). 'metamta.maniphest.reply-handler' => 'ManiphestReplyHandler', // Prefix prepended to mail sent by Maniphest. You can change this to // distinguish between testing and development installs, for example. 'metamta.maniphest.subject-prefix' => '[Maniphest]', // See 'metamta.maniphest.reply-handler-domain'. This does the same thing, // but allows email replies via Differential. 'metamta.differential.reply-handler-domain' => null, // See 'metamta.maniphest.reply-handler'. This does the same thing, but // affects Differential. 'metamta.differential.reply-handler' => 'DifferentialReplyHandler', // Prefix prepended to mail sent by Differential. 'metamta.differential.subject-prefix' => '[Differential]', + // By default, Phabricator generates unique reply-to addresses and sends a + // separate email to each recipient when you enable reply handling. This is + // more secure than using "From" to establish user identity, but can mean + // users may receive multiple emails when they are on mailing lists. Instead, + // you can use a single, non-unique reply to address and authenticate users + // based on the "From" address by setting this to 'true'. This trades away + // a little bit of security for convenience, but it's reasonable in many + // installs. Object interactions are still protected using hashes in the + // single public email address, so objects can not be replied to blindly. + 'metamta.public-replies' => false, + // -- Auth ------------------------------------------------------------------ // // Can users login with a username/password, or by following the link from // a password reset email? You can disable this and configure one or more // OAuth providers instead. 'auth.password-auth-enabled' => true, // Maximum number of simultaneous web sessions each user is permitted to have. // Setting this to "1" will prevent a user from logging in on more than one // browser at the same time. 'auth.sessions.web' => 5, // Maximum number of simultaneous Conduit sessions each user is permitted // to have. 'auth.sessions.conduit' => 3, // -- Accounts -------------------------------------------------------------- // // Is basic account information (email, real name, profile picture) editable? // If you set up Phabricator to automatically synchronize account information // from some other authoritative system, you can disable this to ensure // information remains consistent across both systems. 'account.editable' => true, // -- Facebook ------------------------------------------------------------ // // Can users use Facebook credentials to login to Phabricator? 'facebook.auth-enabled' => false, // Can users use Facebook credentials to create new Phabricator accounts? 'facebook.registration-enabled' => true, // Are Facebook accounts permanently linked to Phabricator accounts, or can // the user unlink them? 'facebook.auth-permanent' => false, // The Facebook "Application ID" to use for Facebook API access. 'facebook.application-id' => null, // The Facebook "Application Secret" to use for Facebook API access. 'facebook.application-secret' => null, // -- Github ---------------------------------------------------------------- // // Can users use Github credentials to login to Phabricator? 'github.auth-enabled' => false, // Can users use Github credentials to create new Phabricator accounts? 'github.registration-enabled' => true, // Are Github accounts permanently linked to Phabricator accounts, or can // the user unlink them? 'github.auth-permanent' => false, // The Github "Client ID" to use for Github API access. 'github.application-id' => null, // The Github "Secret" to use for Github API access. 'github.application-secret' => null, // -- Recaptcha ------------------------------------------------------------- // // Is Recaptcha enabled? If disabled, captchas will not appear. 'recaptcha.enabled' => false, // Your Recaptcha public key, obtained from Recaptcha. 'recaptcha.public-key' => null, // Your Recaptcha private key, obtained from Recaptcha. 'recaptcha.private-key' => null, // -- Misc ------------------------------------------------------------------ // // This is hashed with other inputs to generate CSRF tokens. If you want, you // can change it to some other string which is unique to your install. This // will make your install more secure in a vague, mostly theoretical way. But // it will take you like 3 seconds of mashing on your keyboard to set it up so // you might as well. 'phabricator.csrf-key' => '0b7ec0592e0a2829d8b71df2fa269b2c6172eca3', // This is hashed with other inputs to generate mail tokens. If you want, you // can change it to some other string which is unique to your install. In // particular, you will want to do this if you accidentally send a bunch of // mail somewhere you shouldn't have, to invalidate all old reply-to // addresses. 'phabricator.mail-key' => '5ce3e7e8787f6e40dfae861da315a5cdf1018f12', // Version string displayed in the footer. You probably should leave this // alone. 'phabricator.version' => 'UNSTABLE', // PHP requires that you set a timezone in your php.ini before using date // functions, or it will emit a warning. If this isn't possible (for instance, // because you are using HPHP) you can set some valid constant for // date_default_timezone_set() here and Phabricator will set it on your // behalf, silencing the warning. 'phabricator.timezone' => null, // Phabricator can highlight PHP by default, but if you want syntax // highlighting for other languages you should install the python package // 'Pygments', make sure the 'pygmentize' script is available in the // $PATH of the webserver, and then enable this. 'pygments.enabled' => false, // -- Files ----------------------------------------------------------------- // // Lists which uploaded file types may be viewed in the browser. If a file // has a mime type which does not appear in this list, it will always be // downloaded instead of displayed. This is a security consideration: if a // user uploads a file of type "text/html" and it is displayed as // "text/html", they can easily execute XSS attacks. This is also a usability // consideration, since browsers tend to freak out when viewing enormous // binary files. // // The keys in this array are viewable mime types; the values are the mime // types they will be delivered as when they are viewed in the browser. 'files.viewable-mime-types' => array( 'image/jpeg' => 'image/jpeg', 'image/jpg' => 'image/jpg', 'image/png' => 'image/png', 'image/gif' => 'image/gif', 'text/plain' => 'text/plain; charset=utf-8', ), // Phabricator can proxy images from other servers so you can paste the URI // to a funny picture of a cat into the comment box and have it show up as an // image. However, this means the webserver Phabricator is running on will // make HTTP requests to arbitrary URIs. If the server has access to internal // resources, this could be a security risk. You should only enable it if you // are installed entirely a VPN and VPN access is required to access // Phabricator, or if the webserver has no special access to anything. If // unsure, it is safer to leave this disabled. 'files.enable-proxy' => false, // -- Differential ---------------------------------------------------------- // 'differential.revision-custom-detail-renderer' => null, // Array for custom remarkup rules. The array should have a list of // class names of classes that extend PhutilRemarkupRule 'differential.custom-remarkup-rules' => null, // Array for custom remarkup block rules. The array should have a list of // class names of classes that extend PhutilRemarkupEngineBlockRule 'differential.custom-remarkup-block-rules' => null, // Set display word-wrap widths for Differential. Specify a dictionary of // regular expressions mapping to column widths. The filename will be matched // against each regexp in order until one matches. The default configuration // uses a width of 100 for Java and 80 for other languages. Note that 80 is // the greatest column width of all time. Changes here will not be immediately // reflected in old revisions unless you purge the render cache. 'differential.wordwrap' => array( '/\.java$/' => 100, '/.*/' => 80, ), // Class for appending custom fields to be included in the commit // messages generated by "arc amend". Should inherit // DifferentialCommitMessageModifier 'differential.modify-commit-message-class' => null, // -- Maniphest ------------------------------------------------------------- // 'maniphest.enabled' => true, // -- Remarkup -------------------------------------------------------------- // // If you enable this, linked YouTube videos will be embeded inline. This has // mild security implications (you'll leak referrers to YouTube) and is pretty // silly (but sort of awesome). 'remarkup.enable-embedded-youtube' => false, // -- Customization --------------------------------------------------------- // // Paths to additional phutil libraries to load. 'load-libraries' => array(), 'aphront.default-application-configuration-class' => 'AphrontDefaultApplicationConfiguration', 'controller.oauth-registration' => 'PhabricatorOAuthDefaultRegistrationController', // Directory that phd (the Phabricator daemon control script) should use to // track running daemons. 'phd.pid-directory' => '/var/tmp/phd', // This value is an input to the hash function when building resource hashes. // It has no security value, but if you accidentally poison user caches (by // pushing a bad patch or having something go wrong with a CDN, e.g.) you can // change this to something else and rebuild the Celerity map to break user // caches. Unless you are doing Celerity development, it is exceptionally // unlikely that you need to modify this. 'celerity.resource-hash' => 'd9455ea150622ee044f7931dabfa52aa', // In a development environment, it is desirable to force static resources // (CSS and JS) to be read from disk on every request, so that edits to them // appear when you reload the page even if you haven't updated the resource // maps. This setting ensures requests will be verified against the state on // disk. Generally, you should leave this off in production (caching behavior // and performance improve with it off) but turn it on in development. (These // settings are the defaults.) 'celerity.force-disk-reads' => false, ); diff --git a/src/applications/differential/replyhandler/DifferentialReplyHandler.php b/src/applications/differential/replyhandler/DifferentialReplyHandler.php index ae7be54e1b..516ebd453c 100644 --- a/src/applications/differential/replyhandler/DifferentialReplyHandler.php +++ b/src/applications/differential/replyhandler/DifferentialReplyHandler.php @@ -1,171 +1,175 @@ getDefaultPrivateReplyHandlerEmailAddress($handle, 'D'); } + public function getPublicReplyHandlerEmailAddress() { + return $this->getDefaultPublicReplyHandlerEmailAddress('D'); + } + public function getReplyHandlerDomain() { return PhabricatorEnv::getEnvConfig( 'metamta.differential.reply-handler-domain'); } /* * Generate text like the following from the supported commands. * " * * ACTIONS * Reply to comment, or !accept, !reject, !abandon, !resign, !reclaim. * * " */ public function getReplyHandlerInstructions() { if (!$this->supportsReplies()) { return null; } $supported_commands = $this->getSupportedCommands(); $text = ''; if (empty($supported_commands)) { return $text; } $comment_command_printed = false; if (in_array(DifferentialAction::ACTION_COMMENT, $supported_commands)) { $text .= 'Reply to comment'; $comment_command_printed = true; $supported_commands = array_diff( $supported_commands, array(DifferentialAction::ACTION_COMMENT)); } if (!empty($supported_commands)) { if ($comment_command_printed) { $text .= ', or '; } $modified_commands = array(); foreach ($supported_commands as $command) { $modified_commands[] = '!'.$command; } $text .= implode(', ', $modified_commands); } $text .= "."; return $text; } public function getSupportedCommands() { return array( DifferentialAction::ACTION_COMMENT, DifferentialAction::ACTION_REJECT, DifferentialAction::ACTION_ABANDON, DifferentialAction::ACTION_RECLAIM, DifferentialAction::ACTION_RESIGN, DifferentialAction::ACTION_RETHINK, 'unsubscribe', ); } public function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $this->receivedMail = $mail; $this->handleAction($mail->getCleanTextBody()); } public function handleAction($body) { // all commands start with a bang and separated from the body by a newline // to make sure that actual feedback text couldn't trigger an action. // unrecognized commands will be parsed as part of the comment. $command = DifferentialAction::ACTION_COMMENT; $supported_commands = $this->getSupportedCommands(); $regex = "/\A\n*!(" . implode('|', $supported_commands) . ")\n*/"; $matches = array(); if (preg_match($regex, $body, $matches)) { $command = $matches[1]; $body = trim(str_replace('!' . $command, '', $body)); } $actor = $this->getActor(); if (!$actor) { throw new Exception('No actor is set for the reply action.'); } switch ($command) { case 'unsubscribe': $this->unsubscribeUser($this->getMailReceiver(), $actor); // TODO: Send the user a confirmation email? return null; } try { $editor = new DifferentialCommentEditor( $this->getMailReceiver(), $actor->getPHID(), $command); // NOTE: We have to be careful about this because Facebook's // implementation jumps straight into handleAction() and will not have // a PhabricatorMetaMTAReceivedMail object. if ($this->receivedMail) { $editor->setParentMessageID($this->receivedMail->getMessageID()); } $editor->setMessage($body); $editor->setAddCC(($command != DifferentialAction::ACTION_RESIGN)); $comment = $editor->save(); return $comment->getID(); } catch (Exception $ex) { $exception_mail = new DifferentialExceptionMail( $this->getMailReceiver(), $ex, $body); $exception_mail->setToPHIDs(array($this->getActor()->getPHID())); $exception_mail->send(); throw $ex; } } private function unsubscribeUser( DifferentialRevision $revision, PhabricatorUser $user) { $revision->loadRelationships(); DifferentialRevisionEditor::removeCCAndUpdateRevision( $revision, $user->getPHID(), $user->getPHID()); } } diff --git a/src/applications/maniphest/replyhandler/ManiphestReplyHandler.php b/src/applications/maniphest/replyhandler/ManiphestReplyHandler.php index b86f5685f7..c3f8cfe1c9 100644 --- a/src/applications/maniphest/replyhandler/ManiphestReplyHandler.php +++ b/src/applications/maniphest/replyhandler/ManiphestReplyHandler.php @@ -1,121 +1,125 @@ getDefaultPrivateReplyHandlerEmailAddress($handle, 'T'); } + public function getPublicReplyHandlerEmailAddress() { + return $this->getDefaultPublicReplyHandlerEmailAddress('T'); + } + public function getReplyHandlerDomain() { return PhabricatorEnv::getEnvConfig( 'metamta.maniphest.reply-handler-domain'); } public function getReplyHandlerInstructions() { if ($this->supportsReplies()) { return "Reply to comment or attach files, or !close, !claim, or ". "!unsubscribe."; } else { return null; } } public function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $task = $this->getMailReceiver(); $user = $this->getActor(); $body = $mail->getCleanTextBody(); $body = trim($body); $lines = explode("\n", trim($body)); $first_line = head($lines); $command = null; $matches = null; if (preg_match('/^!(\w+)/', $first_line, $matches)) { $lines = array_slice($lines, 1); $body = implode("\n", $lines); $body = trim($body); $command = $matches[1]; } $xactions = array(); $files = $mail->getAttachments(); if ($files) { $file_xaction = new ManiphestTransaction(); $file_xaction->setAuthorPHID($user->getPHID()); $file_xaction->setTransactionType(ManiphestTransactionType::TYPE_ATTACH); $phid_type = PhabricatorPHIDConstants::PHID_TYPE_FILE; $new = $task->getAttached(); foreach ($files as $file_phid) { $new[$phid_type][$file_phid] = array(); } $file_xaction->setNewValue($new); $xactions[] = $file_xaction; } $ttype = ManiphestTransactionType::TYPE_NONE; $new_value = null; switch ($command) { case 'close': $ttype = ManiphestTransactionType::TYPE_STATUS; $new_value = ManiphestTaskStatus::STATUS_CLOSED_RESOLVED; break; case 'claim': $ttype = ManiphestTransactionType::TYPE_OWNER; $new_value = $user->getPHID(); break; case 'unsubscribe': $ttype = ManiphestTransactionType::TYPE_CCS; $ccs = $task->getCCPHIDs(); foreach ($ccs as $k => $phid) { if ($phid == $user->getPHID()) { unset($ccs[$k]); } } $new_value = array_values($ccs); break; } $xaction = new ManiphestTransaction(); $xaction->setAuthorPHID($user->getPHID()); $xaction->setTransactionType($ttype); $xaction->setNewValue($new_value); $xaction->setComments($body); $xactions[] = $xaction; $editor = new ManiphestTransactionEditor(); $editor->setParentMessageID($mail->getMessageID()); $editor->applyTransactions($task, $xactions); } } diff --git a/src/applications/metamta/replyhandler/base/PhabricatorMailReplyHandler.php b/src/applications/metamta/replyhandler/base/PhabricatorMailReplyHandler.php index f8ab23fa3a..8b2d932bb4 100644 --- a/src/applications/metamta/replyhandler/base/PhabricatorMailReplyHandler.php +++ b/src/applications/metamta/replyhandler/base/PhabricatorMailReplyHandler.php @@ -1,168 +1,192 @@ validateMailReceiver($mail_receiver); $this->mailReceiver = $mail_receiver; return $this; } final public function getMailReceiver() { return $this->mailReceiver; } final public function setActor(PhabricatorUser $actor) { $this->actor = $actor; return $this; } final public function getActor() { return $this->actor; } abstract public function validateMailReceiver($mail_receiver); abstract public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle); abstract public function getReplyHandlerDomain(); abstract public function getReplyHandlerInstructions(); abstract public function receiveEmail(PhabricatorMetaMTAReceivedMail $mail); public function supportsPrivateReplies() { - return (bool)$this->getReplyHandlerDomain(); + return (bool)$this->getReplyHandlerDomain() && + !$this->supportsPublicReplies(); + } + + public function supportsPublicReplies() { + if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) { + return false; + } + return (bool)$this->getPublicReplyHandlerEmailAddress(); } final public function supportsReplies() { return $this->supportsPrivateReplies() || - (bool)$this->getPublicReplyHandlerEmailAddress(); + $this->supportsPublicReplies(); } public function getPublicReplyHandlerEmailAddress() { return null; } final public function multiplexMail( PhabricatorMetaMTAMail $mail_template, array $to_handles, array $cc_handles) { $result = array(); // If private replies are not supported, simply send one email to all // recipients and CCs. This covers cases where we have no reply handler, // or we have a public reply handler. if (!$this->supportsPrivateReplies()) { $mail = clone $mail_template; $mail->addTos(mpull($to_handles, 'getPHID')); $mail->addCCs(mpull($cc_handles, 'getPHID')); $reply_to = $this->getPublicReplyHandlerEmailAddress(); if ($reply_to) { $mail->setReplyTo($reply_to); } $result[] = $mail; return $result; } // Merge all the recipients together. TODO: We could keep the CCs as real // CCs and send to a "noreply@domain.com" type address, but keep it simple // for now. $recipients = mpull($to_handles, null, 'getPHID') + mpull($cc_handles, null, 'getPHID'); // This grouping is just so we can use the public reply-to for any // recipients without a private reply-to, e.g. mailing lists. $groups = array(); foreach ($recipients as $recipient) { $private = $this->getPrivateReplyHandlerEmailAddress($recipient); $groups[$private][] = $recipient; } // When multiplexing mail, explicitly include To/Cc information in the // message body and headers. $add_headers = array(); $body = $mail_template->getBody(); $body .= "\n"; if ($to_handles) { $body .= "To: ".implode(', ', mpull($to_handles, 'getName'))."\n"; $add_headers['X-Phabricator-To'] = $this->formatPHIDList($to_handles); } if ($cc_handles) { $body .= "Cc: ".implode(', ', mpull($cc_handles, 'getName'))."\n"; $add_headers['X-Phabricator-Cc'] = $this->formatPHIDList($cc_handles); } foreach ($groups as $reply_to => $group) { $mail = clone $mail_template; $mail->addTos(mpull($group, 'getPHID')); $mail->setBody($body); foreach ($add_headers as $header => $value) { $mail->addHeader($header, $value); } if (!$reply_to) { $reply_to = $this->getPublicReplyHandlerEmailAddress(); } if ($reply_to) { $mail->setReplyTo($reply_to); } $result[] = $mail; } return $result; } protected function formatPHIDList(array $handles) { $list = array(); foreach ($handles as $handle) { $list[] = '<'.$handle->getPHID().'>'; } return implode(', ', $list); } + protected function getDefaultPublicReplyHandlerEmailAddress($prefix) { + + $receiver = $this->getMailReceiver(); + $receiver_id = $receiver->getID(); + $domain = $this->getReplyHandlerDomain(); + + // We compute a hash using the object's own PHID to prevent an attacker + // from blindly interacting with objects that they haven't ever received + // mail about by just sending to D1@, D2@, etc... + $hash = PhabricatorMetaMTAReceivedMail::computeMailHash( + $receiver->getMailKey(), + $receiver->getPHID()); + + return "{$prefix}{$receiver_id}+public+{$hash}@{$domain}"; + } + protected function getDefaultPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle, $prefix) { if ($handle->getType() != PhabricatorPHIDConstants::PHID_TYPE_USER) { // You must be a real user to get a private reply handler address. return null; } $receiver = $this->getMailReceiver(); $receiver_id = $receiver->getID(); $user_id = $handle->getAlternateID(); $hash = PhabricatorMetaMTAReceivedMail::computeMailHash( $receiver->getMailKey(), $handle->getPHID()); $domain = $this->getReplyHandlerDomain(); return "{$prefix}{$receiver_id}+{$user_id}+{$hash}@{$domain}"; } } diff --git a/src/applications/metamta/replyhandler/base/__init__.php b/src/applications/metamta/replyhandler/base/__init__.php index 5ff3e91560..145c5f6fd3 100644 --- a/src/applications/metamta/replyhandler/base/__init__.php +++ b/src/applications/metamta/replyhandler/base/__init__.php @@ -1,15 +1,16 @@ array( 'headers' => self::SERIALIZATION_JSON, 'bodies' => self::SERIALIZATION_JSON, 'attachments' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } public function setHeaders(array $headers) { // Normalize headers to lowercase. $normalized = array(); foreach ($headers as $name => $value) { $normalized[strtolower($name)] = $value; } $this->headers = $normalized; return $this; } public function getMessageID() { return idx($this->headers, 'message-id'); } public function processReceivedMail() { $to = idx($this->headers, 'to'); // Accept a match either at the beginning of the address or after an open // angle bracket, as in: // "some display name" $matches = null; $ok = preg_match( - '/(?:^|<)((?:D|T)\d+)\+(\d+)\+([a-f0-9]{16})@/U', + '/(?:^|<)((?:D|T)\d+)\+([\w]+)\+([a-f0-9]{16})@/U', $to, $matches); if (!$ok) { return $this->setMessage("Unrecognized 'to' format: {$to}")->save(); } $receiver_name = $matches[1]; $user_id = $matches[2]; $hash = $matches[3]; - $user = id(new PhabricatorUser())->load($user_id); - if (!$user) { - return $this->setMessage("Invalid user '{$user_id}'")->save(); + if ($user_id == 'public') { + if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) { + return $this->setMessage("Public replies not enabled.")->save(); + } + + // Strip the email address out of the 'from' if necessary, since it might + // have some formatting like '"Abraham Lincoln" '. + $from = idx($this->headers, 'from'); + $matches = null; + $ok = preg_match('/<(.*)>/', $from, $matches); + if ($ok) { + $from = $matches[1]; + } + + $user = id(new PhabricatorUser())->loadOneWhere( + 'email = %s', + $from); + if (!$user) { + return $this->setMessage("Invalid public user '{$from}'.")->save(); + } + + $use_user_hash = false; + } else { + $user = id(new PhabricatorUser())->load($user_id); + if (!$user) { + return $this->setMessage("Invalid private user '{$user_id}'.")->save(); + } + + $use_user_hash = true; } if ($user->getIsDisabled()) { return $this->setMessage("User '{$user_id}' is disabled")->save(); } $this->setAuthorPHID($user->getPHID()); $receiver = self::loadReceiverObject($receiver_name); if (!$receiver) { return $this->setMessage("Invalid object '{$receiver_name}'")->save(); } $this->setRelatedPHID($receiver->getPHID()); - $expect_hash = self::computeMailHash( - $receiver->getMailKey(), - $user->getPHID()); + if ($use_user_hash) { + // This is a private reply-to address, check that the user hash is + // correct. + $check_phid = $user->getPHID(); + } else { + // This is a public reply-to address, check that the object hash is + // correct. + $check_phid = $receiver->getPHID(); + } + + $expect_hash = self::computeMailHash($receiver->getMailKey(), $check_phid); if ($expect_hash != $hash) { return $this->setMessage("Invalid mail hash!")->save(); } if ($receiver instanceof ManiphestTask) { $editor = new ManiphestTransactionEditor(); $handler = $editor->buildReplyHandler($receiver); } else if ($receiver instanceof DifferentialRevision) { $handler = DifferentialMail::newReplyHandlerForRevision($receiver); } $handler->setActor($user); $handler->receiveEmail($this); $this->setMessage('OK'); return $this->save(); } public function getCleanTextBody() { $body = idx($this->bodies, 'text'); $parser = new PhabricatorMetaMTAEmailBodyParser($body); return $parser->stripQuotedText(); } public static function loadReceiverObject($receiver_name) { if (!$receiver_name) { return null; } $receiver_type = $receiver_name[0]; $receiver_id = substr($receiver_name, 1); $class_obj = null; switch ($receiver_type) { case 'T': $class_obj = newv('ManiphestTask', array()); break; case 'D': $class_obj = newv('DifferentialRevision', array()); break; default: return null; } return $class_obj->load($receiver_id); } public static function computeMailHash($mail_key, $phid) { $global_mail_key = PhabricatorEnv::getEnvConfig('phabricator.mail-key'); $hash = sha1($mail_key.$global_mail_key.$phid); return substr($hash, 0, 16); } } diff --git a/src/docs/configuration/configuring_inbound_email.diviner b/src/docs/configuration/configuring_inbound_email.diviner index ebc524b738..981b6a9512 100644 --- a/src/docs/configuration/configuring_inbound_email.diviner +++ b/src/docs/configuration/configuring_inbound_email.diviner @@ -1,204 +1,213 @@ @title Configuring Inbound Email @group config This document contains instructions for configuring inbound email, so users may update Differential and Maniphest by replying to messages. = Preamble = This can be extremely difficult to configure correctly. This is doubly true if you use sendmail. There are basically a few approaches available: - Use SendGrid (), which is very easy but is not free. - Run your own MTA, which can be quite harrowing to configure but is free. - Tell the Phabricator devteam about another service you'd like support for, this stuff is seriously terrible to configure on your own. = Configuring Phabricator = By default, Phabricator uses a "noreply@example.com" email address as the 'From' (configurable with ##metamta.default-address##) and sets 'Reply-To' to the user generating the email (e.g., by making a comment), if the mail was generated by a user action. This means that users can reply (or reply-all) to email to discuss changes, but the conversation won't be recorded in Phabricator and users will not be able to take actions like claiming tasks or requesting changes to revisions. To change this behavior so that users can interact with objects in Phabricator over email, set these configuration keys: - ##metamta.differential.reply-handler-domain##: enables email replies for Differential. - ##metamta.maniphest.reply-handler-domain##: enables email replies for Maniphest. Set these keys to some domain which you configure according to the instructions below, e.g. "##phabricator.example.com##". You can set these both to the same domain, and will generally want to. Once you set these keys, emails will use a 'Reply-To' like "##T123+273+af310f9220ad@example.com##", which -- when configured correctly, according to the instructions below -- will parse incoming email and allow users to interact with Maniphest tasks and Differential revisions over email. = Security = The email reply channel is "somewhat" authenticated. Each reply-to address is unique to the recipient and includes a hash of user information and a unique object ID, so it can only be used to update that object and only be used to act on behalf of the recipient. However, if an address is leaked (which is fairly easy -- for instance, forwarding an email will leak a live reply address, or a user might take a screenshot), //anyone// who can send mail to your reply-to domain may interact with the object the email relates to as the user who leaked the mail. Because the authentication around email has this weakness, some actions (like accepting revisions) are not permitted over email. This implementation is an attempt to balance utility and security, but makes some sacrifices on both sides to achieve it because of the difficulty of authenticating senders in the general case (e.g., where you are an open source project and need to interact with users whose email accounts you have no control over). If you leak a bunch of reply-to addresses by accident, you can change ##phabricator.mail-key## in your configuration to invalidate all the old hashes. +You can also set ##metamta.public-replies##, which will change how Phabricator +delivers email. Instead of sending each recipient a unique mail with a personal +reply-to address, it will send a single email to everyone with a public reply-to +address. This decreases security because anyone who can spoof a "From" address +can act as another user, but increases convenience if you use mailing lists and, +practically, is a reasonable setting for many installs. The reply-to address +will still contain a hash unique to the object it represents, so users who have +not received an email about an object can not blindly interact with it. + NOTE: Phabricator does not currently attempt to verify "From" addresses because this is technically complex, seems unreasonably difficult in the general case, and no installs have had a need for it yet. If you have a specific case where a reasonable mechanism exists to provide sender verification (e.g., DKIM signatures are sufficient to authenticate the sender under your configuration, or you are willing to require all users to sign their email), file a feature request. = Testing = You can view a log of received mail by going to MetaMTA -> Received in the Phabricator web interface. This can help you determine if mail is being delivered to Phabricator or not. You can also use the "Test Receiver" button, but note that this just simulates receiving mail and doesn't send any information over the network. It is primarily aimed at developing email handlers: it will still work properly if your inbound email configuration is incorrect or even disabled. = SendGrid = To use SendGrid, you need a SendGrid account with access to the "Parse API" for inbound email. Provided you have such an account, configure it like this: - Configure an MX record according to SendGrid's instructions, i.e. add ##phabricator.example.com MX 10 mx.sendgrid.net.## or similar. - Go to the "Parse Incoming Emails" page on SendGrid () and add the domain as the "Hostname". - Add the URL ##https://phabricator.example.com/mail/sendgrid/## as the "Url", using your domain (and HTTP instead of HTTPS if you are not configured with SSL). - If you get an error that the hostname "can't be located or verified", it means your MX record is either incorrectly configured or hasn't propagated yet. - Set ##metamta.maniphest.reply-handler-domain## and/or ##metamta.differential.reply-handler-domain## to "##phabricator.example.com##" (whatever you configured the MX record for), depending on whether you want to support email replies for Maniphest, Differential, or both. That's it! If everything is working properly you should be able to send email to ##anything@phabricator.example.com## and it should appear in the "Received" tab of MetaMTA within a few seconds. = Installing Mailparse = If you're going to run your own MTA, you need to install the PECL mailparse extension. In theory, you can do that with: $ sudo pecl install mailparse You may run into an error like "needs mbstring". If so, try: $ sudo yum install php-mbstring # or equivalent $ sudo pecl install -n mailparse If you get a linker error like this: COUNTEREXAMPLE PHP Warning: PHP Startup: Unable to load dynamic library '/usr/lib64/php/modules/mailparse.so' - /usr/lib64/php/modules/mailparse.so: undefined symbol: mbfl_name2no_encoding in Unknown on line 0 ...you need to edit your php.ini file so that mbstring.so is loaded **before** mailparse.so. This is not the default if you have individual files in ##php.d/##. = MTA: Configuring Sendmail = Before you can configure Sendmail, you need to install Mailparse. See the section "Installing Mailparse" above. Sendmail is very difficult to configure. First, you need to configure it for your domain so that mail can be delivered correctly. In broad strokes, this probably means something like this: - add an MX record; - make sendmail listen on external interfaces; - open up port 25 if necessary (e.g., in your EC2 security policy); - add your host to /etc/mail/local-host-names; and - restart sendmail. Now, you can actually configure sendmail to deliver to Phabricator. In ##/etc/aliases##, add an entry like this: phabricator: "| /path/to/phabricator/scripts/mail/mail_handler.php " ...where is the PHABRICATOR_ENV the script should run under. Run ##sudo newaliases##. Now you likely need to symlink this script into ##/etc/smrsh/##: sudo ln -s /path/to/phabricator/scripts/mail/mail_handler.php /etc/smrsh/ Finally, edit ##/etc/mail/virtusertable## and add an entry like this: @yourdomain.com phabricator@localhost That will forward all mail to @yourdomain.com to the Phabricator processing script. Run ##sudo /etc/mail/make## or similar and then restart sendmail with ##sudo /etc/init.d/sendmail restart##. = MTA: Configuring Lamson = Before you can configure Lamson, you need to install Mailparse. See the section "Installing Mailparse" above. In contrast to Sendmail, Lamson is relatively easy to configure. It is fairly minimal, and is suitable for a development or testing environment. Lamson listens for incoming SMTP mails and passes the content directly to Phabricator. To get started, follow the provided instructions () to set up an instance. One likely deployment issue is that binding to port 25 requires root privileges. Lamson is capable of starting as root then dropping privileges, but you must supply ##-uid## and ##-gid## arguments to do so, as demonstrated by Step 8 in Lamson's deployment tutorial (located here: ). The Lamson handler code itself is very concise; it merely needs to pass the content of the email to Phabricator: import logging, subprocess from lamson.routing import route, stateless from lamson import view - + PHABRICATOR_ROOT = "/path/to/phabricator" PHABRICATOR_ENV = "custom/myconf" LOGGING_ENABLED = True - + @route("(address)@(host)", address=".+") @stateless def START(message, address=None, host=None): if LOGGING_ENABLED: logging.debug("%s", message.original) process = subprocess.Popen([PHABRICATOR_ROOT + "scripts/mail/mail_handler.php",PHABRICATOR_ENV],stdin=subprocess.PIPE) process.communicate(message.original)