diff --git a/src/applications/audit/mail/PhabricatorAuditReplyHandler.php b/src/applications/audit/mail/PhabricatorAuditReplyHandler.php index db254fb4d1..7214943f76 100644 --- a/src/applications/audit/mail/PhabricatorAuditReplyHandler.php +++ b/src/applications/audit/mail/PhabricatorAuditReplyHandler.php @@ -1,63 +1,55 @@ <?php final class PhabricatorAuditReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PhabricatorRepositoryCommit)) { throw new Exception('Mail receiver is not a commit!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'C'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('C'); } public function getReplyHandlerDomain() { return $this->getCustomReplyHandlerDomainIfExists( 'metamta.diffusion.reply-handler-domain'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - return pht('Reply to comment.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $commit = $this->getMailReceiver(); $actor = $this->getActor(); $message = $mail->getCleanTextBody(); $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $mail->getID(), )); // TODO: Support !raise, !accept, etc. $xactions = array(); $xactions[] = id(new PhabricatorAuditTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment( id(new PhabricatorAuditTransactionComment()) ->setCommitPHID($commit->getPHID()) ->setContent($message)); $editor = id(new PhabricatorAuditEditor()) ->setActor($actor) ->setContentSource($content_source) ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs()) ->setContinueOnMissingFields(true) ->applyTransactions($commit, $xactions); } } diff --git a/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php b/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php index 9339c571c3..550574a199 100644 --- a/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php +++ b/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php @@ -1,227 +1,229 @@ <?php final class PhabricatorExtraConfigSetupCheck extends PhabricatorSetupCheck { public function getDefaultGroup() { return self::GROUP_OTHER; } protected function executeChecks() { $ancient_config = self::getAncientConfig(); $all_keys = PhabricatorEnv::getAllConfigKeys(); $all_keys = array_keys($all_keys); sort($all_keys); $defined_keys = PhabricatorApplicationConfigOptions::loadAllOptions(); foreach ($all_keys as $key) { if (isset($defined_keys[$key])) { continue; } if (isset($ancient_config[$key])) { $summary = pht( 'This option has been removed. You may delete it at your '. 'convenience.'); $message = pht( "The configuration option '%s' has been removed. You may delete ". "it at your convenience.". "\n\n%s", $key, $ancient_config[$key]); $short = pht('Obsolete Config'); $name = pht('Obsolete Configuration Option "%s"', $key); } else { $summary = pht('This option is not recognized. It may be misspelled.'); $message = pht( "The configuration option '%s' is not recognized. It may be ". "misspelled, or it might have existed in an older version of ". "Phabricator. It has no effect, and should be corrected or deleted.", $key); $short = pht('Unknown Config'); $name = pht('Unknown Configuration Option "%s"', $key); } $issue = $this->newIssue('config.unknown.'.$key) ->setShortName($short) ->setName($name) ->setSummary($summary); $stack = PhabricatorEnv::getConfigSourceStack(); $stack = $stack->getStack(); $found = array(); $found_local = false; $found_database = false; foreach ($stack as $source_key => $source) { $value = $source->getKeys(array($key)); if ($value) { $found[] = $source->getName(); if ($source instanceof PhabricatorConfigDatabaseSource) { $found_database = true; } if ($source instanceof PhabricatorConfigLocalSource) { $found_local = true; } } } $message = $message."\n\n".pht( 'This configuration value is defined in these %d '. 'configuration source(s): %s.', count($found), implode(', ', $found)); $issue->setMessage($message); if ($found_local) { $command = csprintf('phabricator/ $ ./bin/config delete %s', $key); $issue->addCommand($command); } if ($found_database) { $issue->addPhabricatorConfig($key); } } } /** * Return a map of deleted config options. Keys are option keys; values are * explanations of what happened to the option. */ public static function getAncientConfig() { $reason_auth = pht( 'This option has been migrated to the "Auth" application. Your old '. 'configuration is still in effect, but now stored in "Auth" instead of '. 'configuration. Going forward, you can manage authentication from '. 'the web UI.'); $auth_config = array( 'controller.oauth-registration', 'auth.password-auth-enabled', 'facebook.auth-enabled', 'facebook.registration-enabled', 'facebook.auth-permanent', 'facebook.application-id', 'facebook.application-secret', 'facebook.require-https-auth', 'github.auth-enabled', 'github.registration-enabled', 'github.auth-permanent', 'github.application-id', 'github.application-secret', 'google.auth-enabled', 'google.registration-enabled', 'google.auth-permanent', 'google.application-id', 'google.application-secret', 'ldap.auth-enabled', 'ldap.hostname', 'ldap.port', 'ldap.base_dn', 'ldap.search_attribute', 'ldap.search-first', 'ldap.username-attribute', 'ldap.real_name_attributes', 'ldap.activedirectory_domain', 'ldap.version', 'ldap.referrals', 'ldap.anonymous-user-name', 'ldap.anonymous-user-password', 'ldap.start-tls', 'disqus.auth-enabled', 'disqus.registration-enabled', 'disqus.auth-permanent', 'disqus.application-id', 'disqus.application-secret', 'phabricator.oauth-uri', 'phabricator.auth-enabled', 'phabricator.registration-enabled', 'phabricator.auth-permanent', 'phabricator.application-id', 'phabricator.application-secret', ); $ancient_config = array_fill_keys($auth_config, $reason_auth); $markup_reason = pht( 'Custom remarkup rules are now added by subclassing '. 'PhabricatorRemarkupCustomInlineRule or '. 'PhabricatorRemarkupCustomBlockRule.'); $session_reason = pht( 'Sessions now expire and are garbage collected rather than having an '. 'arbitrary concurrency limit.'); $differential_field_reason = pht( 'All Differential fields are now managed through the configuration '. 'option "%s". Use that option to configure which fields are shown.', 'differential.fields'); $ancient_config += array( 'phid.external-loaders' => pht( 'External loaders have been replaced. Extend `PhabricatorPHIDType` '. 'to implement new PHID and handle types.'), 'maniphest.custom-task-extensions-class' => pht( 'Maniphest fields are now loaded automatically. You can configure '. 'them with `maniphest.fields`.'), 'maniphest.custom-fields' => pht( 'Maniphest fields are now defined in '. '`maniphest.custom-field-definitions`. Existing definitions have '. 'been migrated.'), 'differential.custom-remarkup-rules' => $markup_reason, 'differential.custom-remarkup-block-rules' => $markup_reason, 'auth.sshkeys.enabled' => pht( 'SSH keys are now actually useful, so they are always enabled.'), 'differential.anonymous-access' => pht( 'Phabricator now has meaningful global access controls. See '. '`policy.allow-public`.'), 'celerity.resource-path' => pht( 'An alternate resource map is no longer supported. Instead, use '. 'multiple maps. See T4222.'), 'metamta.send-immediately' => pht( 'Mail is now always delivered by the daemons.'), 'auth.sessions.conduit' => $session_reason, 'auth.sessions.web' => $session_reason, 'tokenizer.ondemand' => pht( 'Phabricator now manages typeahead strategies automatically.'), 'differential.revision-custom-detail-renderer' => pht( 'Obsolete; use standard rendering events instead.'), 'differential.show-host-field' => $differential_field_reason, 'differential.show-test-plan-field' => $differential_field_reason, 'differential.field-selector' => $differential_field_reason, 'phabricator.show-beta-applications' => pht( 'This option has been renamed to `phabricator.show-prototypes` '. 'to emphasize the unfinished nature of many prototype applications. '. 'Your existing setting has been migrated.'), 'notification.user' => pht( 'The notification server no longer requires root permissions. Start '. 'the server as the user you want it to run under.'), 'notification.debug' => pht( 'Notifications no longer have a dedicated debugging mode.'), 'translation.provider' => pht( 'The translation implementation has changed and providers are no '. 'longer used or supported.'), 'config.mask' => pht( 'Use `config.hide` instead of this option.'), 'phd.start-taskmasters' => pht( 'Taskmasters now use an autoscaling pool. You can configure the '. 'pool size with `phd.taskmasters`.'), 'storage.engine-selector' => pht( 'Phabricator now automatically discovers available storage engines '. 'at runtime.'), 'storage.upload-size-limit' => pht( 'Phabricator now supports arbitrarily large files. Consult the '. 'documentation for configuration details.'), 'security.allow-outbound-http' => pht( 'This option has been replaced with the more granular option '. '`security.outbound-blacklist`.'), + 'metamta.reply.show-hints' => pht( + 'Phabricator no longer shows reply hints in mail.'), ); return $ancient_config; } } diff --git a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php index 0a992d5ba9..7c0b990992 100644 --- a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php +++ b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php @@ -1,362 +1,354 @@ <?php final class PhabricatorMetaMTAConfigOptions extends PhabricatorApplicationConfigOptions { public function getName() { return pht('Mail'); } public function getDescription() { return pht('Configure Mail.'); } public function getFontIcon() { return 'fa-send'; } public function getGroup() { return 'core'; } public function getOptions() { $send_as_user_desc = $this->deformat(pht(<<<EODOC 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 probably should not enable this or all of your outgoing email might vanish into SFP blackholes. - If your install is anything else, you're safer leaving this off, at least initially, since the risk in turning it on is that your outgoing mail will never arrive. EODOC )); $one_mail_per_recipient_desc = $this->deformat(pht(<<<EODOC When a message is sent to multiple recipients (for example, several reviewers on a code review), Phabricator can either deliver one email to everyone (e.g., "To: alincoln, usgrant, htaft") or separate emails to each user (e.g., "To: alincoln", "To: usgrant", "To: htaft"). The major advantages and disadvantages of each approach are: - One mail to everyone: - Recipients can see To/Cc at a glance. - If you use mailing lists, you won't get duplicate mail if you're a normal recipient and also Cc'd on a mailing list. - Getting threading to work properly is harder, and probably requires making mail less useful by turning off options. - Sometimes people will "Reply All" and everyone will get two mails, one from the user and one from Phabricator turning their mail into a comment. - Not supported with a private reply-to address. - Mails are sent in the server default translation. - One mail to each user: - Recipients need to look in the mail body to see To/Cc. - If you use mailing lists, recipients may sometimes get duplicate mail. - Getting threading to work properly is easier, and threading settings can be customzied by each user. - "Reply All" no longer spams all other users. - Required if private reply-to addresses are configured. - Mails are sent in the language of user preference. In the code, splitting one outbound email into one-per-recipient is sometimes referred to as "multiplexing". EODOC )); $herald_hints_description = $this->deformat(pht(<<<EODOC You can disable the Herald hints in email if users prefer smaller messages. These are the links under the header "WHY DID I GET THIS EMAIL?". If you set this to true, they will not appear in any mail. Users can still navigate to the links via the web interface. EODOC )); $reply_hints_description = $this->deformat(pht(<<<EODOC You can disable the hints under "REPLY HANDLER ACTIONS" if users prefer smaller messages. The actions themselves will still work properly. EODOC )); $recipient_hints_description = $this->deformat(pht(<<<EODOC You can disable the "To:" and "Cc:" footers in mail if users prefer smaller messages. EODOC )); $bulk_description = $this->deformat(pht(<<<EODOC If this option is enabled, Phabricator will add a "Precedence: bulk" header to transactional mail (e.g., Differential, Maniphest and Herald notifications). This may improve the behavior of some auto-responder software and prevent it from replying. However, it may also cause deliverability issues -- notably, you currently can not send this header via Amazon SES, and enabling this option with SES will prevent delivery of any affected mail. EODOC )); $email_preferences_description = $this->deformat(pht(<<<EODOC You can disable the email preference link in emails if users prefer smaller emails. EODOC )); $re_prefix_description = $this->deformat(pht(<<<EODOC Mail.app on OS X Lion won't respect threading headers unless the subject is prefixed with "Re:". If you enable this option, Phabricator will add "Re:" to the subject line of all mail which is expected to thread. If you've set 'metamta.one-mail-per-recipient', users can override this setting in their preferences. EODOC )); $vary_subjects_description = $this->deformat(pht(<<<EODOC If true, allow MetaMTA to change mail subjects to put text like '[Accepted]' and '[Commented]' in them. This makes subjects more useful, but might break threading on some clients. If you've set 'metamta.one-mail-per-recipient', users can override this setting in their preferences. EODOC )); $reply_to_description = $this->deformat(pht(<<<EODOC If you enable {{metamta.public-replies}}, Phabricator uses "From" to authenticate users. You can additionally enable this setting to try to authenticate with 'Reply-To'. Note that this is completely spoofable and insecure (any user can set any 'Reply-To' address) but depending on the nature of your install or other deliverability conditions this might be okay. Generally, you can't do much more by spoofing Reply-To than be annoying (you can write but not read content). But this is still **COMPLETELY INSECURE**. EODOC )); $adapter_description = $this->deformat(pht(<<<EODOC 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. A number of other mailers are available (e.g., SES, SendGrid, SMTP, custom mailers), consult "Configuring Outbound Email" in the documentation for details. EODOC )); $placeholder_description = $this->deformat(pht(<<<EODOC When sending a message that has no To recipient (i.e. all recipients are CC'd, for example when multiplexing mail), set the To field to the following value. If no value is set, messages with no To will have their CCs upgraded to To. EODOC )); $public_replies_description = $this->deformat(pht(<<<EODOC 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. EODOC )); $single_description = $this->deformat(pht(<<<EODOC If you want to use a single mailbox for Phabricator reply mail, you can use this and set a common prefix for reply addresses generated by Phabricator. It will make use of the fact that a mail-address such as `phabricator+D123+1hjk213h@example.com` will be delivered to the `phabricator` user's mailbox. Set this to the left part of the email address and it will be prepended to all generated reply addresses. For example, if you want to use `phabricator@example.com`, this should be set to `phabricator`. EODOC )); $address_description = $this->deformat(pht(<<<EODOC When email is sent, what format should Phabricator use for user's email addresses? Valid values are: - `short`: 'gwashington <gwashington@example.com>' - `real`: 'George Washington <gwashington@example.com>' - `full`: 'gwashington (George Washington) <gwashington@example.com>' The default is `full`. EODOC )); return array( $this->newOption( 'metamta.default-address', 'string', 'noreply@phabricator.example.com') ->setDescription(pht('Default "From" address.')), $this->newOption( 'metamta.domain', 'string', 'phabricator.example.com') ->setDescription(pht('Domain used to generate Message-IDs.')), $this->newOption( 'metamta.mail-adapter', 'class', 'PhabricatorMailImplementationPHPMailerLiteAdapter') ->setBaseClass('PhabricatorMailImplementationAdapter') ->setSummary(pht('Control how mail is sent.')) ->setDescription($adapter_description), $this->newOption( 'metamta.one-mail-per-recipient', 'bool', true) ->setBoolOptions( array( pht('Send Mail To Each Recipient'), pht('Send Mail To All Recipients'), )) ->setSummary( pht( 'Controls whether Phabricator sends one email with multiple '. 'recipients in the "To:" line, or multiple emails, each with a '. 'single recipient in the "To:" line.')) ->setDescription($one_mail_per_recipient_desc), $this->newOption('metamta.can-send-as-user', 'bool', false) ->setBoolOptions( array( pht('Send as User Taking Action'), pht('Send as Phabricator'), )) ->setSummary( pht( 'Controls whether Phabricator sends email "From" users.')) ->setDescription($send_as_user_desc), $this->newOption( 'metamta.reply-handler-domain', 'string', null) ->setLocked(true) ->setDescription(pht( 'Domain used for reply email addresses. Some applications can '. 'override this configuration with a different domain.')) ->addExample('phabricator.example.com', ''), - $this->newOption('metamta.reply.show-hints', 'bool', true) - ->setBoolOptions( - array( - pht('Show Reply Handler Hints'), - pht('No Reply Handler Hints'), - )) - ->setSummary(pht('Show hints about reply handler actions in email.')) - ->setDescription($reply_hints_description), $this->newOption('metamta.herald.show-hints', 'bool', true) ->setBoolOptions( array( pht('Show Herald Hints'), pht('No Herald Hints'), )) ->setSummary(pht('Show hints about Herald rules in email.')) ->setDescription($herald_hints_description), $this->newOption('metamta.recipients.show-hints', 'bool', true) ->setBoolOptions( array( pht('Show Recipient Hints'), pht('No Recipient Hints'), )) ->setSummary(pht('Show "To:" and "Cc:" footer hints in email.')) ->setDescription($recipient_hints_description), $this->newOption('metamta.email-preferences', 'bool', true) ->setBoolOptions( array( pht('Show Email Preferences Link'), pht('No Email Preferences Link'), )) ->setSummary(pht('Show email preferences link in email.')) ->setDescription($email_preferences_description), $this->newOption('metamta.precedence-bulk', 'bool', false) ->setBoolOptions( array( pht('Add "Precedence: bulk" Header'), pht('No "Precedence: bulk" Header'), )) ->setSummary(pht('Control the "Precedence: bulk" header.')) ->setDescription($bulk_description), $this->newOption('metamta.re-prefix', 'bool', false) ->setBoolOptions( array( pht('Force "Re:" Subject Prefix'), pht('No "Re:" Subject Prefix'), )) ->setSummary(pht('Control "Re:" subject prefix, for Mail.app.')) ->setDescription($re_prefix_description), $this->newOption('metamta.vary-subjects', 'bool', true) ->setBoolOptions( array( pht('Allow Varied Subjects'), pht('Always Use the Same Thread Subject'), )) ->setSummary(pht('Control subject variance, for some mail clients.')) ->setDescription($vary_subjects_description), $this->newOption('metamta.insecure-auth-with-reply-to', 'bool', false) ->setBoolOptions( array( pht('Allow Insecure Reply-To Auth'), pht('Disallow Reply-To Auth'), )) ->setSummary(pht('Trust "Reply-To" headers for authentication.')) ->setDescription($reply_to_description), $this->newOption('metamta.placeholder-to-recipient', 'string', null) ->setSummary(pht('Placeholder for mail with only CCs.')) ->setDescription($placeholder_description), $this->newOption('metamta.public-replies', 'bool', false) ->setBoolOptions( array( pht('Use Public Replies (Less Secure)'), pht('Use Private Replies (More Secure)'), )) ->setSummary( pht( 'Phabricator can use less-secure but mailing list friendly public '. 'reply addresses.')) ->setDescription($public_replies_description), $this->newOption('metamta.single-reply-handler-prefix', 'string', null) ->setSummary( pht('Allow Phabricator to use a single mailbox for all replies.')) ->setDescription($single_description), $this->newOption('metamta.user-address-format', 'enum', 'full') ->setEnumOptions( array( 'short' => 'short', 'real' => 'real', 'full' => 'full', )) ->setSummary(pht('Control how Phabricator renders user names in mail.')) ->setDescription($address_description) ->addExample('gwashington <gwashington@example.com>', 'short') ->addExample('George Washington <gwashington@example.com>', 'real') ->addExample( 'gwashington (George Washington) <gwashington@example.com>', 'full'), $this->newOption('metamta.email-body-limit', 'int', 524288) ->setDescription( pht( 'You can set a limit for the maximum byte size of outbound mail. '. 'Mail which is larger than this limit will be truncated before '. 'being sent. This can be useful if your MTA rejects mail which '. 'exceeds some limit (this is reasonably common). Specify a value '. 'in bytes.')) ->setSummary(pht('Global cap for size of generated emails (bytes).')) ->addExample(524288, pht('Truncate at 512KB')) ->addExample(1048576, pht('Truncate at 1MB')), ); } } diff --git a/src/applications/conpherence/mail/ConpherenceReplyHandler.php b/src/applications/conpherence/mail/ConpherenceReplyHandler.php index c695cfbc39..5c55f7aa61 100644 --- a/src/applications/conpherence/mail/ConpherenceReplyHandler.php +++ b/src/applications/conpherence/mail/ConpherenceReplyHandler.php @@ -1,94 +1,86 @@ <?php final class ConpherenceReplyHandler extends PhabricatorMailReplyHandler { private $mailAddedParticipantPHIDs; public function setMailAddedParticipantPHIDs(array $phids) { $this->mailAddedParticipantPHIDs = $phids; return $this; } public function getMailAddedParticipantPHIDs() { return $this->mailAddedParticipantPHIDs; } public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof ConpherenceThread)) { throw new Exception('Mail receiver is not a ConpherenceThread!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'Z'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('Z'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - return pht('Reply to comment and attach files.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $conpherence = $this->getMailReceiver(); $user = $this->getActor(); if (!$conpherence->getPHID()) { $conpherence ->attachParticipants(array()) ->attachFilePHIDs(array()); } else { $edge_type = PhabricatorObjectHasFileEdgeType::EDGECONST; $file_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $conpherence->getPHID(), $edge_type); $conpherence->attachFilePHIDs($file_phids); $participants = id(new ConpherenceParticipant()) ->loadAllWhere('conpherencePHID = %s', $conpherence->getPHID()); $participants = mpull($participants, null, 'getParticipantPHID'); $conpherence->attachParticipants($participants); } $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $mail->getID(), )); $editor = id(new ConpherenceEditor()) ->setActor($user) ->setContentSource($content_source) ->setParentMessageID($mail->getMessageID()); $body = $mail->getCleanTextBody(); $file_phids = $mail->getAttachments(); $body = $this->enhanceBodyWithAttachments( $body, $file_phids, '{F%d}'); $xactions = array(); if ($this->getMailAddedParticipantPHIDs()) { $xactions[] = id(new ConpherenceTransaction()) ->setTransactionType(ConpherenceTransactionType::TYPE_PARTICIPANTS) ->setNewValue(array('+' => $this->getMailAddedParticipantPHIDs())); } $xactions = array_merge( $xactions, $editor->generateTransactionsFromText( $user, $conpherence, $body)); $editor->applyTransactions($conpherence, $xactions); return $conpherence; } } diff --git a/src/applications/differential/mail/DifferentialReplyHandler.php b/src/applications/differential/mail/DifferentialReplyHandler.php index 7dd64b51c5..4a773dd2bc 100644 --- a/src/applications/differential/mail/DifferentialReplyHandler.php +++ b/src/applications/differential/mail/DifferentialReplyHandler.php @@ -1,176 +1,129 @@ <?php /** * NOTE: Do not extend this! * * @concrete-extensible */ class DifferentialReplyHandler extends PhabricatorMailReplyHandler { private $receivedMail; public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof DifferentialRevision)) { throw new Exception('Receiver is not a DifferentialRevision!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'D'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('D'); } public function getReplyHandlerDomain() { return $this->getCustomReplyHandlerDomainIfExists( '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 .= pht('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() { $actions = array( DifferentialAction::ACTION_COMMENT, DifferentialAction::ACTION_REJECT, DifferentialAction::ACTION_ABANDON, DifferentialAction::ACTION_RECLAIM, DifferentialAction::ACTION_RESIGN, DifferentialAction::ACTION_RETHINK, 'unsubscribe', ); if (PhabricatorEnv::getEnvConfig('differential.enable-email-accept')) { $actions[] = DifferentialAction::ACTION_ACCEPT; } return $actions; } protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $this->receivedMail = $mail; $this->handleAction($mail->getCleanTextBody(), $mail->getAttachments()); } public function handleAction($body, array $attachments) { // 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': id(new PhabricatorSubscriptionsEditor()) ->setActor($actor) ->setObject($this->getMailReceiver()) ->unsubscribe(array($actor->getPHID())) ->save(); // TODO: Send the user a confirmation email? return null; } $body = $this->enhanceBodyWithAttachments($body, $attachments); $xactions = array(); if ($command && ($command != DifferentialAction::ACTION_COMMENT)) { $xactions[] = id(new DifferentialTransaction()) ->setTransactionType(DifferentialTransaction::TYPE_ACTION) ->setNewValue($command); } if (strlen($body)) { $xactions[] = id(new DifferentialTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment( id(new DifferentialTransactionComment()) ->setContent($body)); } $editor = id(new DifferentialTransactionEditor()) ->setActor($actor) ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs()) ->setContinueOnMissingFields(true) ->setContinueOnNoEffect(true); // 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) { $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $this->receivedMail->getID(), )); $editor->setContentSource($content_source); $editor->setParentMessageID($this->receivedMail->getMessageID()); } else { $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_LEGACY, array()); $editor->setContentSource($content_source); } $editor->applyTransactions($this->getMailReceiver(), $xactions); } } diff --git a/src/applications/files/mail/FileReplyHandler.php b/src/applications/files/mail/FileReplyHandler.php index 99da44afb3..aafd2fd43b 100644 --- a/src/applications/files/mail/FileReplyHandler.php +++ b/src/applications/files/mail/FileReplyHandler.php @@ -1,77 +1,69 @@ <?php final class FileReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PhabricatorFile)) { throw new Exception('Mail receiver is not a PhabricatorFile.'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'F'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('F'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - return pht('Reply to comment or !unsubscribe.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $actor = $this->getActor(); $file = $this->getMailReceiver(); $body_data = $mail->parseBody(); $body = $body_data['body']; $body = $this->enhanceBodyWithAttachments($body, $mail->getAttachments()); $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $mail->getID(), )); $xactions = array(); $command = $body_data['body']; switch ($command) { case 'unsubscribe': $xaction = id(new PhabricatorFileTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue(array('-' => array($actor->getPHID()))); $xactions[] = $xaction; break; } $xactions[] = id(new PhabricatorFileTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment( id(new PhabricatorFileTransactionComment()) ->setContent($body)); $editor = id(new PhabricatorFileEditor()) ->setActor($actor) ->setContentSource($content_source) ->setContinueOnNoEffect(true) ->setIsPreview(false); try { $xactions = $editor->applyTransactions($file, $xactions); } catch (PhabricatorApplicationTransactionNoEffectException $ex) { // just do nothing, though unclear why you're sending a blank email return true; } $head_xaction = head($xactions); return $head_xaction->getID(); } } diff --git a/src/applications/fund/mail/FundInitiativeReplyHandler.php b/src/applications/fund/mail/FundInitiativeReplyHandler.php index a2bd2587df..0e5db2aab5 100644 --- a/src/applications/fund/mail/FundInitiativeReplyHandler.php +++ b/src/applications/fund/mail/FundInitiativeReplyHandler.php @@ -1,34 +1,25 @@ <?php final class FundInitiativeReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof FundInitiative)) { throw new Exception('Mail receiver is not a FundInitiative!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'I'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('I'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - // TODO: Implement. - return null; - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { // TODO: Implement. return null; } } diff --git a/src/applications/legalpad/mail/LegalpadReplyHandler.php b/src/applications/legalpad/mail/LegalpadReplyHandler.php index 08699d9bdd..9dd6cc8890 100644 --- a/src/applications/legalpad/mail/LegalpadReplyHandler.php +++ b/src/applications/legalpad/mail/LegalpadReplyHandler.php @@ -1,81 +1,73 @@ <?php final class LegalpadReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof LegalpadDocument)) { throw new Exception('Mail receiver is not a LegalpadDocument!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'L'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('L'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - return pht('Reply to comment or !unsubscribe.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $actor = $this->getActor(); $document = $this->getMailReceiver(); $body_data = $mail->parseBody(); $body = $body_data['body']; $body = $this->enhanceBodyWithAttachments($body, $mail->getAttachments()); $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $mail->getID(), )); $xactions = array(); $command = $body_data['command']; switch ($command) { case 'unsubscribe': $xaction = id(new LegalpadTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue(array('-' => array($actor->getPHID()))); $xactions[] = $xaction; break; } $xactions[] = id(new LegalpadTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment( id(new LegalpadTransactionComment()) ->setDocumentID($document->getID()) ->setLineNumber(0) ->setLineLength(0) ->setContent($body)); $editor = id(new LegalpadDocumentEditor()) ->setActor($actor) ->setContentSource($content_source) ->setContinueOnNoEffect(true) ->setIsPreview(false); try { $xactions = $editor->applyTransactions($document, $xactions); } catch (PhabricatorApplicationTransactionNoEffectException $ex) { // just do nothing, though unclear why you're sending a blank email return true; } $head_xaction = head($xactions); return $head_xaction->getID(); } } diff --git a/src/applications/macro/mail/PhabricatorMacroReplyHandler.php b/src/applications/macro/mail/PhabricatorMacroReplyHandler.php index c2cf90352b..1b70b8872a 100644 --- a/src/applications/macro/mail/PhabricatorMacroReplyHandler.php +++ b/src/applications/macro/mail/PhabricatorMacroReplyHandler.php @@ -1,40 +1,30 @@ <?php final class PhabricatorMacroReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PhabricatorFileImageMacro)) { throw new Exception('Mail receiver is not a PhabricatorFileImageMacro!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'MCRO'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('MCRO'); } public function getReplyHandlerDomain() { return $this->getCustomReplyHandlerDomainIfExists( 'metamta.macro.reply-handler-domain'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - // TODO: Implement. - return null; - return pht('Reply to comment.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { // TODO: Implement this. return null; } } diff --git a/src/applications/maniphest/mail/ManiphestReplyHandler.php b/src/applications/maniphest/mail/ManiphestReplyHandler.php index 1c04093f4c..7f74c8a569 100644 --- a/src/applications/maniphest/mail/ManiphestReplyHandler.php +++ b/src/applications/maniphest/mail/ManiphestReplyHandler.php @@ -1,190 +1,180 @@ <?php final class ManiphestReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof ManiphestTask)) { throw new Exception('Mail receiver is not a ManiphestTask!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'T'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('T'); } public function getReplyHandlerDomain() { return $this->getCustomReplyHandlerDomainIfExists( 'metamta.maniphest.reply-handler-domain'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - return pht( - 'Reply to comment or attach files, or !close, !claim, '. - '!unsubscribe or !assign <username>.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { // NOTE: We'll drop in here on both the "reply to a task" and "create a // new task" workflows! Make sure you test both if you make changes! $task = $this->getMailReceiver(); $is_new_task = !$task->getID(); $user = $this->getActor(); $body_data = $mail->parseBody(); $body = $body_data['body']; $body = $this->enhanceBodyWithAttachments($body, $mail->getAttachments()); $xactions = array(); $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $mail->getID(), )); $template = new ManiphestTransaction(); $is_unsub = false; if ($is_new_task) { $task = ManiphestTask::initializeNewTask($user); $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_STATUS) ->setNewValue(ManiphestTaskStatus::getDefaultStatus()); $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_TITLE) ->setNewValue(nonempty($mail->getSubject(), pht('Untitled Task'))); $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_DESCRIPTION) ->setNewValue($body); } else { $command = $body_data['command']; $command_value = $body_data['command_value']; $ttype = PhabricatorTransactions::TYPE_COMMENT; $new_value = null; switch ($command) { case 'close': $ttype = ManiphestTransaction::TYPE_STATUS; $new_value = ManiphestTaskStatus::getDefaultClosedStatus(); break; case 'claim': $ttype = ManiphestTransaction::TYPE_OWNER; $new_value = $user->getPHID(); break; case 'assign': $ttype = ManiphestTransaction::TYPE_OWNER; if ($command_value) { $assign_users = id(new PhabricatorPeopleQuery()) ->setViewer($user) ->withUsernames(array($command_value)) ->execute(); if ($assign_users) { $assign_user = head($assign_users); $new_value = $assign_user->getPHID(); } } // assign to the user by default if (!$new_value) { $new_value = $user->getPHID(); } break; case 'unsubscribe': $is_unsub = true; $ttype = PhabricatorTransactions::TYPE_SUBSCRIBERS; $ccs = $task->getSubscriberPHIDs(); foreach ($ccs as $k => $phid) { if ($phid == $user->getPHID()) { unset($ccs[$k]); } } $new_value = array('=' => array_values($ccs)); break; } if ($ttype != PhabricatorTransactions::TYPE_COMMENT) { $xaction = clone $template; $xaction->setTransactionType($ttype); $xaction->setNewValue($new_value); $xactions[] = $xaction; } if (strlen($body)) { $xaction = clone $template; $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT); $xaction->attachComment( id(new ManiphestTransactionComment()) ->setContent($body)); $xactions[] = $xaction; } } $ccs = $mail->loadCCPHIDs(); $old_ccs = $task->getSubscriberPHIDs(); $new_ccs = array_merge($old_ccs, $ccs); if (!$is_unsub) { $new_ccs[] = $user->getPHID(); } $new_ccs = array_unique($new_ccs); if (array_diff($new_ccs, $old_ccs)) { $cc_xaction = clone $template; $cc_xaction->setTransactionType( PhabricatorTransactions::TYPE_SUBSCRIBERS); $cc_xaction->setNewValue(array('=' => $new_ccs)); $xactions[] = $cc_xaction; } $event = new PhabricatorEvent( PhabricatorEventType::TYPE_MANIPHEST_WILLEDITTASK, array( 'task' => $task, 'mail' => $mail, 'new' => $is_new_task, 'transactions' => $xactions, )); $event->setUser($user); PhutilEventEngine::dispatchEvent($event); $task = $event->getValue('task'); $xactions = $event->getValue('transactions'); $editor = id(new ManiphestTransactionEditor()) ->setActor($user) ->setParentMessageID($mail->getMessageID()) ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setContentSource($content_source); if ($this->getApplicationEmail()) { $editor->setApplicationEmail($this->getApplicationEmail()); } $editor->applyTransactions($task, $xactions); $event = new PhabricatorEvent( PhabricatorEventType::TYPE_MANIPHEST_DIDEDITTASK, array( 'task' => $task, 'new' => $is_new_task, 'transactions' => $xactions, )); $event->setUser($user); PhutilEventEngine::dispatchEvent($event); } } diff --git a/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php b/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php index a4d503cf8b..d3518fe65c 100644 --- a/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php +++ b/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php @@ -1,374 +1,373 @@ <?php abstract class PhabricatorMailReplyHandler { private $mailReceiver; private $applicationEmail; private $actor; private $excludePHIDs = array(); final public function setMailReceiver($mail_receiver) { $this->validateMailReceiver($mail_receiver); $this->mailReceiver = $mail_receiver; return $this; } final public function getMailReceiver() { return $this->mailReceiver; } public function setApplicationEmail( PhabricatorMetaMTAApplicationEmail $email) { $this->applicationEmail = $email; return $this; } public function getApplicationEmail() { return $this->applicationEmail; } final public function setActor(PhabricatorUser $actor) { $this->actor = $actor; return $this; } final public function getActor() { return $this->actor; } final public function setExcludeMailRecipientPHIDs(array $exclude) { $this->excludePHIDs = $exclude; return $this; } final public function getExcludeMailRecipientPHIDs() { return $this->excludePHIDs; } abstract public function validateMailReceiver($mail_receiver); abstract public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle); public function getReplyHandlerDomain() { return $this->getDefaultReplyHandlerDomain(); } protected function getCustomReplyHandlerDomainIfExists($config_key) { $domain = PhabricatorEnv::getEnvConfig($config_key); if ($domain) { return $domain; } return $this->getDefaultReplyHandlerDomain(); } private function getDefaultReplyHandlerDomain() { return PhabricatorEnv::getEnvConfig( 'metamta.reply-handler-domain'); } - abstract public function getReplyHandlerInstructions(); abstract protected function receiveEmail( PhabricatorMetaMTAReceivedMail $mail); public function processEmail(PhabricatorMetaMTAReceivedMail $mail) { $this->dropEmptyMail($mail); return $this->receiveEmail($mail); } private function dropEmptyMail(PhabricatorMetaMTAReceivedMail $mail) { $body = $mail->getCleanTextBody(); $attachments = $mail->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($mail->getToAddresses()) == 1) && (count($mail->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 '. 'text appears at the top of your message: quoted replies, inline '. 'text, and signatures are discarded and ignored.')); } public function supportsPrivateReplies() { return (bool)$this->getReplyHandlerDomain() && !$this->supportsPublicReplies(); } public function supportsPublicReplies() { if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) { return false; } if (!$this->getReplyHandlerDomain()) { return false; } return (bool)$this->getPublicReplyHandlerEmailAddress(); } final public function supportsReplies() { return $this->supportsPrivateReplies() || $this->supportsPublicReplies(); } public function getPublicReplyHandlerEmailAddress() { return null; } final public function getRecipientsSummary( array $to_handles, array $cc_handles) { assert_instances_of($to_handles, 'PhabricatorObjectHandle'); assert_instances_of($cc_handles, 'PhabricatorObjectHandle'); $body = ''; if (PhabricatorEnv::getEnvConfig('metamta.recipients.show-hints')) { if ($to_handles) { $body .= "To: ".implode(', ', mpull($to_handles, 'getName'))."\n"; } if ($cc_handles) { $body .= "Cc: ".implode(', ', mpull($cc_handles, 'getName'))."\n"; } } return $body; } final public function getRecipientsSummaryHTML( array $to_handles, array $cc_handles) { assert_instances_of($to_handles, 'PhabricatorObjectHandle'); assert_instances_of($cc_handles, 'PhabricatorObjectHandle'); if (PhabricatorEnv::getEnvConfig('metamta.recipients.show-hints')) { $body = array(); if ($to_handles) { $body[] = phutil_tag('strong', array(), 'To: '); $body[] = phutil_implode_html(', ', mpull($to_handles, 'getName')); $body[] = phutil_tag('br'); } if ($cc_handles) { $body[] = phutil_tag('strong', array(), 'Cc: '); $body[] = phutil_implode_html(', ', mpull($cc_handles, 'getName')); $body[] = phutil_tag('br'); } return phutil_tag('div', array(), $body); } else { return ''; } } final public function multiplexMail( PhabricatorMetaMTAMail $mail_template, array $to_handles, array $cc_handles) { assert_instances_of($to_handles, 'PhabricatorObjectHandle'); assert_instances_of($cc_handles, 'PhabricatorObjectHandle'); $result = array(); // If MetaMTA is configured to always multiplex, skip the single-email // case. if (!PhabricatorMetaMTAMail::shouldMultiplexAllMail()) { // 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')); if ($this->supportsPublicReplies()) { $reply_to = $this->getPublicReplyHandlerEmailAddress(); $mail->setReplyTo($reply_to); } $result[] = $mail; return $result; } } // TODO: This is pretty messy. We should really be doing all of this // multiplexing in the task queue, but that requires significant rewriting // in the general case. ApplicationTransactions can do it fairly easily, // but other mail sites currently can not, so we need to support this // junky version until they catch up and we can swap things over. $to_handles = $this->expandRecipientHandles($to_handles); $cc_handles = $this->expandRecipientHandles($cc_handles); $tos = mpull($to_handles, null, 'getPHID'); $ccs = mpull($cc_handles, null, 'getPHID'); // 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 = $tos + $ccs; // When multiplexing mail, explicitly include To/Cc information in the // message body and headers. $mail_template = clone $mail_template; $mail_template->addPHIDHeaders('X-Phabricator-To', array_keys($tos)); $mail_template->addPHIDHeaders('X-Phabricator-Cc', array_keys($ccs)); $body = $mail_template->getBody(); $body .= "\n"; $body .= $this->getRecipientsSummary($to_handles, $cc_handles); $html_body = $mail_template->getHTMLBody(); if (strlen($html_body)) { $html_body .= hsprintf('%s', $this->getRecipientsSummaryHTML($to_handles, $cc_handles)); } foreach ($recipients as $phid => $recipient) { $mail = clone $mail_template; if (isset($to_handles[$phid])) { $mail->addTos(array($phid)); } else if (isset($cc_handles[$phid])) { $mail->addCCs(array($phid)); } else { // not good - they should be a to or a cc continue; } $mail->setBody($body); $mail->setHTMLBody($html_body); $reply_to = null; if (!$reply_to && $this->supportsPrivateReplies()) { $reply_to = $this->getPrivateReplyHandlerEmailAddress($recipient); } if (!$reply_to && $this->supportsPublicReplies()) { $reply_to = $this->getPublicReplyHandlerEmailAddress(); } if ($reply_to) { $mail->setReplyTo($reply_to); } $result[] = $mail; } return $result; } 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 = PhabricatorObjectMailReceiver::computeMailHash( $receiver->getMailKey(), $receiver->getPHID()); $address = "{$prefix}{$receiver_id}+public+{$hash}@{$domain}"; return $this->getSingleReplyHandlerPrefix($address); } protected function getSingleReplyHandlerPrefix($address) { $single_handle_prefix = PhabricatorEnv::getEnvConfig( 'metamta.single-reply-handler-prefix'); return ($single_handle_prefix) ? $single_handle_prefix.'+'.$address : $address; } protected function getDefaultPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle, $prefix) { if ($handle->getType() != PhabricatorPeopleUserPHIDType::TYPECONST) { // You must be a real user to get a private reply handler address. return null; } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($handle->getPHID())) ->executeOne(); if (!$user) { // This may happen if a user was subscribed to something, and was then // deleted. return null; } $receiver = $this->getMailReceiver(); $receiver_id = $receiver->getID(); $user_id = $user->getID(); $hash = PhabricatorObjectMailReceiver::computeMailHash( $receiver->getMailKey(), $handle->getPHID()); $domain = $this->getReplyHandlerDomain(); $address = "{$prefix}{$receiver_id}+{$user_id}+{$hash}@{$domain}"; return $this->getSingleReplyHandlerPrefix($address); } final protected function enhanceBodyWithAttachments( $body, array $attachments, $format = '- {F%d, layout=link}') { if (!$attachments) { return $body; } $files = id(new PhabricatorFileQuery()) ->setViewer($this->getActor()) ->withPHIDs($attachments) ->execute(); // if we have some text then double return before adding our file list if ($body) { $body .= "\n\n"; } foreach ($files as $file) { $file_str = sprintf($format, $file->getID()); $body .= $file_str."\n"; } return rtrim($body); } private function expandRecipientHandles(array $handles) { if (!$handles) { return array(); } $phids = mpull($handles, 'getPHID'); $results = id(new PhabricatorMetaMTAMemberQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($phids) ->executeExpansion(); return id(new PhabricatorHandleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($results) ->execute(); } } diff --git a/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php b/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php index 8d6c82cfc2..03f89467f4 100644 --- a/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php +++ b/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php @@ -1,254 +1,235 @@ <?php /** * Render the body of an application email by building it up section-by-section. * * @task compose Composition * @task render Rendering */ final class PhabricatorMetaMTAMailBody { private $sections = array(); private $htmlSections = array(); private $attachments = array(); private $viewer; public function getViewer() { return $this->viewer; } public function setViewer($viewer) { $this->viewer = $viewer; } /* -( Composition )-------------------------------------------------------- */ /** * Add a raw block of text to the email. This will be rendered as-is. * * @param string Block of text. * @return this * @task compose */ public function addRawSection($text) { if (strlen($text)) { $text = rtrim($text); $this->sections[] = $text; $this->htmlSections[] = phutil_escape_html_newlines( phutil_tag('div', array(), $text)); } return $this; } public function addRemarkupSection($text) { try { $engine = PhabricatorMarkupEngine::newMarkupEngine(array()); $engine->setConfig('viewer', $this->getViewer()); $engine->setMode(PhutilRemarkupEngine::MODE_TEXT); $styled_text = $engine->markupText($text); $this->sections[] = $styled_text; } catch (Exception $ex) { phlog($ex); $this->sections[] = $text; } try { $mail_engine = PhabricatorMarkupEngine::newMarkupEngine(array()); $mail_engine->setConfig('viewer', $this->getViewer()); $mail_engine->setMode(PhutilRemarkupEngine::MODE_HTML_MAIL); $mail_engine->setConfig( 'uri.base', PhabricatorEnv::getProductionURI('/')); $html = $mail_engine->markupText($text); $this->htmlSections[] = $html; } catch (Exception $ex) { phlog($ex); $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) { $this->htmlSections[] = phutil_safe_html($html); return $this; } /** * Add a block of text with a section header. This is rendered like this: * * HEADER * Text is indented. * * @param string Header text. * @param string Section text. * @return this * @task compose */ 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[] = array( phutil_tag( 'div', array(), array( phutil_tag('strong', array(), $header), phutil_tag('div', array(), $html_fragment), )), ); return $this; } public function addLinkSection($header, $link) { $html = phutil_tag('a', array('href' => $link), $link); $this->addPlaintextSection($header, $link); $this->addHTMLSection($header, $html); return $this; } /** * Add a Herald section with a rule management URI and a transcript URI. * * @param string URI to rule transcripts. * @return this * @task compose */ public function addHeraldSection($xscript_uri) { if (!PhabricatorEnv::getEnvConfig('metamta.herald.show-hints')) { return $this; } $this->addLinkSection( pht('WHY DID I GET THIS EMAIL?'), PhabricatorEnv::getProductionURI($xscript_uri)); return $this; } - /** - * Add a section with reply handler instructions. - * - * @param string Reply handler instructions. - * @return this - * @task compose - */ - public function addReplySection($instructions) { - if (!PhabricatorEnv::getEnvConfig('metamta.reply.show-hints')) { - return $this; - } - if (!strlen($instructions)) { - return $this; - } - - $this->addTextSection(pht('REPLY HANDLER ACTIONS'), $instructions); - - return $this; - } - /** * Add a section with a link to email preferences. * * @return this * @task compose */ public function addEmailPreferenceSection() { if (!PhabricatorEnv::getEnvConfig('metamta.email-preferences')) { return $this; } $href = PhabricatorEnv::getProductionURI( '/settings/panel/emailpreferences/'); $this->addLinkSection(pht('EMAIL PREFERENCES'), $href); return $this; } + /** * Add an attachment. * * @param PhabricatorMetaMTAAttachment Attachment. * @return this * @task compose */ public function addAttachment(PhabricatorMetaMTAAttachment $attachment) { $this->attachments[] = $attachment; return $this; } /* -( Rendering )---------------------------------------------------------- */ /** * Render the email body. * * @return string Rendered body. * @task render */ public function render() { return implode("\n\n", $this->sections)."\n"; } public function renderHTML() { $br = phutil_tag('br'); $body = phutil_implode_html($br, $this->htmlSections); return (string)hsprintf('%s', array($body, $br)); } /** * Retrieve attachments. * * @return list<PhabricatorMetaMTAAttachment> Attachments. * @task render */ public function getAttachments() { return $this->attachments; } /** * Indent a block of text for rendering under a section heading. * * @param string Text to indent. * @return string Indented text. * @task render */ private function indent($text) { return rtrim(" ".str_replace("\n", "\n ", $text)); } } diff --git a/src/applications/metamta/view/__tests__/PhabricatorMetaMTAMailBodyTestCase.php b/src/applications/metamta/view/__tests__/PhabricatorMetaMTAMailBodyTestCase.php index 021dd504dd..e8415c7675 100644 --- a/src/applications/metamta/view/__tests__/PhabricatorMetaMTAMailBodyTestCase.php +++ b/src/applications/metamta/view/__tests__/PhabricatorMetaMTAMailBodyTestCase.php @@ -1,72 +1,47 @@ <?php final class PhabricatorMetaMTAMailBodyTestCase extends PhabricatorTestCase { public function testBodyRender() { $expect = <<<EOTEXT salmon HEADER bass trout WHY DID I GET THIS EMAIL? http://test.com/xscript/ -REPLY HANDLER ACTIONS - pike - EOTEXT; - $this->assertEmail($expect, true, true); + $this->assertEmail($expect, true); } public function testBodyRenderNoHerald() { $expect = <<<EOTEXT salmon HEADER bass trout -REPLY HANDLER ACTIONS - pike - -EOTEXT; - - $this->assertEmail($expect, false, true); - } - - - public function testBodyRenderNoReply() { - $expect = <<<EOTEXT -salmon - -HEADER - bass - trout - -WHY DID I GET THIS EMAIL? - http://test.com/xscript/ - EOTEXT; - $this->assertEmail($expect, true, false); + $this->assertEmail($expect, false); } - private function assertEmail($expect, $herald_hints, $reply_hints) { + private function assertEmail($expect, $herald_hints) { $env = PhabricatorEnv::beginScopedEnv(); $env->overrideEnvConfig('phabricator.production-uri', 'http://test.com/'); $env->overrideEnvConfig('metamta.herald.show-hints', $herald_hints); - $env->overrideEnvConfig('metamta.reply.show-hints', $reply_hints); $body = new PhabricatorMetaMTAMailBody(); $body->addRawSection('salmon'); $body->addTextSection('HEADER', "bass\ntrout\n"); $body->addHeraldSection('/xscript/'); - $body->addReplySection('pike'); $this->assertEqual($expect, $body->render()); } } diff --git a/src/applications/owners/mail/OwnersPackageReplyHandler.php b/src/applications/owners/mail/OwnersPackageReplyHandler.php index 5f2c7c5f12..3eb8a8a3c4 100644 --- a/src/applications/owners/mail/OwnersPackageReplyHandler.php +++ b/src/applications/owners/mail/OwnersPackageReplyHandler.php @@ -1,30 +1,26 @@ <?php final class OwnersPackageReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PhabricatorOwnersPackage)) { throw new Exception('Receiver is not a PhabricatorOwnersPackage!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return null; } public function getPublicReplyHandlerEmailAddress() { return null; } public function getReplyHandlerDomain() { return null; } - public function getReplyHandlerInstructions() { - return null; - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { return; } } diff --git a/src/applications/paste/mail/PasteReplyHandler.php b/src/applications/paste/mail/PasteReplyHandler.php index f94eceb09a..1cc5e91d70 100644 --- a/src/applications/paste/mail/PasteReplyHandler.php +++ b/src/applications/paste/mail/PasteReplyHandler.php @@ -1,80 +1,72 @@ <?php final class PasteReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PhabricatorPaste)) { throw new Exception('Mail receiver is not a PhabricatorPaste.'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'P'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('P'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - return pht('Reply to comment or !unsubscribe.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $actor = $this->getActor(); $paste = $this->getMailReceiver(); $body_data = $mail->parseBody(); $body = $body_data['body']; $body = $this->enhanceBodyWithAttachments($body, $mail->getAttachments()); $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $mail->getID(), )); $lines = explode("\n", trim($body)); $first_line = head($lines); $xactions = array(); $command = $body_data['command']; switch ($command) { case 'unsubscribe': $xaction = id(new PhabricatorPasteTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue(array('-' => array($actor->getPHID()))); $xactions[] = $xaction; break; } $xactions[] = id(new PhabricatorPasteTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment( id(new PhabricatorPasteTransactionComment()) ->setContent($body)); $editor = id(new PhabricatorPasteEditor()) ->setActor($actor) ->setContentSource($content_source) ->setContinueOnNoEffect(true) ->setIsPreview(false); try { $xactions = $editor->applyTransactions($paste, $xactions); } catch (PhabricatorApplicationTransactionNoEffectException $ex) { // just do nothing, though unclear why you're sending a blank email return true; } $head_xaction = head($xactions); return $head_xaction->getID(); } } diff --git a/src/applications/pholio/mail/PholioReplyHandler.php b/src/applications/pholio/mail/PholioReplyHandler.php index ce7578ff92..0e8249f78d 100644 --- a/src/applications/pholio/mail/PholioReplyHandler.php +++ b/src/applications/pholio/mail/PholioReplyHandler.php @@ -1,40 +1,30 @@ <?php final class PholioReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PholioMock)) { throw new Exception('Mail receiver is not a PholioMock!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'M'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('M'); } public function getReplyHandlerDomain() { return $this->getCustomReplyHandlerDomainIfExists( 'metamta.pholio.reply-handler-domain'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - // TODO: Implement. - return null; - return pht('Reply to comment.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { // TODO: Implement this. return null; } } diff --git a/src/applications/phortune/mail/PhortuneCartReplyHandler.php b/src/applications/phortune/mail/PhortuneCartReplyHandler.php index c48d1ce264..09ae40f95c 100644 --- a/src/applications/phortune/mail/PhortuneCartReplyHandler.php +++ b/src/applications/phortune/mail/PhortuneCartReplyHandler.php @@ -1,34 +1,25 @@ <?php final class PhortuneCartReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PhortuneCart)) { throw new Exception('Mail receiver is not a PhortuneCart!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'CART'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('CART'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - // TODO: Implement. - return null; - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { // TODO: Implement. return null; } } diff --git a/src/applications/phriction/mail/PhrictionReplyHandler.php b/src/applications/phriction/mail/PhrictionReplyHandler.php index b458e13dc0..65df12aaf0 100644 --- a/src/applications/phriction/mail/PhrictionReplyHandler.php +++ b/src/applications/phriction/mail/PhrictionReplyHandler.php @@ -1,37 +1,28 @@ <?php final class PhrictionReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PhrictionDocument)) { throw new Exception('Mail receiver is not a PhrictionDocument!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress( $handle, PhrictionDocumentPHIDType::TYPECONST); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress( PhrictionDocumentPHIDType::TYPECONST); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - // TODO: Implement. - return null; - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { // TODO: Implement. return null; } } diff --git a/src/applications/ponder/mail/PonderQuestionReplyHandler.php b/src/applications/ponder/mail/PonderQuestionReplyHandler.php index f5923f39dd..cc0ab8a939 100644 --- a/src/applications/ponder/mail/PonderQuestionReplyHandler.php +++ b/src/applications/ponder/mail/PonderQuestionReplyHandler.php @@ -1,27 +1,23 @@ <?php final class PonderQuestionReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof PonderQuestion)) { throw new Exception('Mail receiver is not a PonderQuestion!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'Q'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('Q'); } - public function getReplyHandlerInstructions() { - return null; - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { // ignore this entirely for now } } diff --git a/src/applications/releeph/mail/ReleephRequestReplyHandler.php b/src/applications/releeph/mail/ReleephRequestReplyHandler.php index ec231b63c5..4c36078ea5 100644 --- a/src/applications/releeph/mail/ReleephRequestReplyHandler.php +++ b/src/applications/releeph/mail/ReleephRequestReplyHandler.php @@ -1,55 +1,47 @@ <?php final class ReleephRequestReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof ReleephRequest)) { throw new Exception('Mail receiver is not a ReleephRequest!'); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'RERQ'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('RERQ'); } - public function getReplyHandlerInstructions() { - if ($this->supportsReplies()) { - return pht('Reply to comment.'); - } else { - return null; - } - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $rq = $this->getMailReceiver(); $user = $this->getActor(); $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $mail->getID(), )); $editor = id(new ReleephRequestTransactionalEditor()) ->setActor($user) ->setContentSource($content_source) ->setParentMessageID($mail->getMessageID()); $body = $mail->getCleanTextBody(); $xactions = array(); $xactions[] = id(new ReleephRequestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment($body); $editor->applyTransactions($rq, $xactions); return $rq; } } diff --git a/src/applications/repository/mail/PhabricatorRepositoryPushReplyHandler.php b/src/applications/repository/mail/PhabricatorRepositoryPushReplyHandler.php index 09f111868b..d902396d56 100644 --- a/src/applications/repository/mail/PhabricatorRepositoryPushReplyHandler.php +++ b/src/applications/repository/mail/PhabricatorRepositoryPushReplyHandler.php @@ -1,27 +1,23 @@ <?php final class PhabricatorRepositoryPushReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { return; } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return null; } public function getReplyHandlerDomain() { return null; } - public function getReplyHandlerInstructions() { - return null; - } - protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { return; } } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index 19b94d9c5f..6da9c72d5a 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -1,2664 +1,2660 @@ <?php /** * @task mail Sending Mail * @task feed Publishing Feed Stories * @task search Search Index * @task files Integration with Files */ abstract class PhabricatorApplicationTransactionEditor extends PhabricatorEditor { private $contentSource; private $object; private $xactions; private $isNewObject; private $mentionedPHIDs; private $continueOnNoEffect; private $continueOnMissingFields; private $parentMessageID; private $heraldAdapter; private $heraldTranscript; private $subscribers; private $unmentionablePHIDMap = array(); private $applicationEmail; private $isPreview; private $isHeraldEditor; private $isInverseEdgeEditor; private $actingAsPHID; private $disableEmail; /** * Get the class name for the application this editor is a part of. * * Uninstalling the application will disable the editor. * * @return string Editor's application class name. */ abstract public function getEditorApplicationClass(); /** * Get a description of the objects this editor edits, like "Differential * Revisions". * * @return string Human readable description of edited objects. */ abstract public function getEditorObjectsDescription(); public function setActingAsPHID($acting_as_phid) { $this->actingAsPHID = $acting_as_phid; return $this; } public function getActingAsPHID() { if ($this->actingAsPHID) { return $this->actingAsPHID; } return $this->getActor()->getPHID(); } /** * When the editor tries to apply transactions that have no effect, should * it raise an exception (default) or drop them and continue? * * Generally, you will set this flag for edits coming from "Edit" interfaces, * and leave it cleared for edits coming from "Comment" interfaces, so the * user will get a useful error if they try to submit a comment that does * nothing (e.g., empty comment with a status change that has already been * performed by another user). * * @param bool True to drop transactions without effect and continue. * @return this */ public function setContinueOnNoEffect($continue) { $this->continueOnNoEffect = $continue; return $this; } public function getContinueOnNoEffect() { return $this->continueOnNoEffect; } /** * When the editor tries to apply transactions which don't populate all of * an object's required fields, should it raise an exception (default) or * drop them and continue? * * For example, if a user adds a new required custom field (like "Severity") * to a task, all existing tasks won't have it populated. When users * manually edit existing tasks, it's usually desirable to have them provide * a severity. However, other operations (like batch editing just the * owner of a task) will fail by default. * * By setting this flag for edit operations which apply to specific fields * (like the priority, batch, and merge editors in Maniphest), these * operations can continue to function even if an object is outdated. * * @param bool True to continue when transactions don't completely satisfy * all required fields. * @return this */ public function setContinueOnMissingFields($continue_on_missing_fields) { $this->continueOnMissingFields = $continue_on_missing_fields; return $this; } public function getContinueOnMissingFields() { return $this->continueOnMissingFields; } /** * Not strictly necessary, but reply handlers ideally set this value to * make email threading work better. */ public function setParentMessageID($parent_message_id) { $this->parentMessageID = $parent_message_id; return $this; } public function getParentMessageID() { return $this->parentMessageID; } public function getIsNewObject() { return $this->isNewObject; } protected function getMentionedPHIDs() { return $this->mentionedPHIDs; } public function setIsPreview($is_preview) { $this->isPreview = $is_preview; return $this; } public function getIsPreview() { return $this->isPreview; } public function setIsInverseEdgeEditor($is_inverse_edge_editor) { $this->isInverseEdgeEditor = $is_inverse_edge_editor; return $this; } public function getIsInverseEdgeEditor() { return $this->isInverseEdgeEditor; } public function setIsHeraldEditor($is_herald_editor) { $this->isHeraldEditor = $is_herald_editor; return $this; } public function getIsHeraldEditor() { return $this->isHeraldEditor; } /** * Prevent this editor from generating email when applying transactions. * * @param bool True to disable email. * @return this */ public function setDisableEmail($disable_email) { $this->disableEmail = $disable_email; return $this; } public function getDisableEmail() { return $this->disableEmail; } public function setUnmentionablePHIDMap(array $map) { $this->unmentionablePHIDMap = $map; return $this; } public function getUnmentionablePHIDMap() { return $this->unmentionablePHIDMap; } protected function shouldEnableMentions( PhabricatorLiskDAO $object, array $xactions) { return true; } public function setApplicationEmail( PhabricatorMetaMTAApplicationEmail $email) { $this->applicationEmail = $email; return $this; } public function getApplicationEmail() { return $this->applicationEmail; } public function getTransactionTypes() { $types = array(); if ($this->object instanceof PhabricatorSubscribableInterface) { $types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS; } if ($this->object instanceof PhabricatorCustomFieldInterface) { $types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD; } if ($this->object instanceof HarbormasterBuildableInterface) { $types[] = PhabricatorTransactions::TYPE_BUILDABLE; } if ($this->object instanceof PhabricatorTokenReceiverInterface) { $types[] = PhabricatorTransactions::TYPE_TOKEN; } if ($this->object instanceof PhabricatorProjectInterface) { $types[] = PhabricatorTransactions::TYPE_EDGE; } return $types; } private function adjustTransactionValues( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { if ($xaction->shouldGenerateOldValue()) { $old = $this->getTransactionOldValue($object, $xaction); $xaction->setOldValue($old); } $new = $this->getTransactionNewValue($object, $xaction); $xaction->setNewValue($new); } private function getTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: return array_values($this->subscribers); case PhabricatorTransactions::TYPE_VIEW_POLICY: return $object->getViewPolicy(); case PhabricatorTransactions::TYPE_EDIT_POLICY: return $object->getEditPolicy(); case PhabricatorTransactions::TYPE_JOIN_POLICY: return $object->getJoinPolicy(); case PhabricatorTransactions::TYPE_EDGE: $edge_type = $xaction->getMetadataValue('edge:type'); if (!$edge_type) { throw new Exception("Edge transaction has no 'edge:type'!"); } $old_edges = array(); if ($object->getPHID()) { $edge_src = $object->getPHID(); $old_edges = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(array($edge_src)) ->withEdgeTypes(array($edge_type)) ->needEdgeData(true) ->execute(); $old_edges = $old_edges[$edge_src][$edge_type]; } return $old_edges; case PhabricatorTransactions::TYPE_CUSTOMFIELD: // NOTE: Custom fields have their old value pre-populated when they are // built by PhabricatorCustomFieldList. return $xaction->getOldValue(); case PhabricatorTransactions::TYPE_COMMENT: return null; default: return $this->getCustomTransactionOldValue($object, $xaction); } } private function getTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: return $this->getPHIDTransactionNewValue($xaction); case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case PhabricatorTransactions::TYPE_JOIN_POLICY: case PhabricatorTransactions::TYPE_BUILDABLE: case PhabricatorTransactions::TYPE_TOKEN: case PhabricatorTransactions::TYPE_INLINESTATE: return $xaction->getNewValue(); case PhabricatorTransactions::TYPE_EDGE: return $this->getEdgeTransactionNewValue($xaction); case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->getNewValueFromApplicationTransactions($xaction); case PhabricatorTransactions::TYPE_COMMENT: return null; default: return $this->getCustomTransactionNewValue($object, $xaction); } } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception('Capability not supported!'); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception('Capability not supported!'); } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COMMENT: return $xaction->hasComment(); case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->getApplicationTransactionHasEffect($xaction); case PhabricatorTransactions::TYPE_EDGE: // A straight value comparison here doesn't always get the right // result, because newly added edges aren't fully populated. Instead, // compare the changes in a more granular way. $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $old_dst = array_keys($old); $new_dst = array_keys($new); // NOTE: For now, we don't consider edge reordering to be a change. // We have very few order-dependent edges and effectively no order // oriented UI. This might change in the future. sort($old_dst); sort($new_dst); if ($old_dst !== $new_dst) { // We've added or removed edges, so this transaction definitely // has an effect. return true; } // We haven't added or removed edges, but we might have changed // edge data. foreach ($old as $key => $old_value) { $new_value = $new[$key]; if ($old_value['data'] !== $new_value['data']) { return true; } } return false; } return ($xaction->getOldValue() !== $xaction->getNewValue()); } protected function shouldApplyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function applyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { throw new PhutilMethodNotImplementedException(); } private function applyInternalEffects( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_BUILDABLE: case PhabricatorTransactions::TYPE_TOKEN: return; case PhabricatorTransactions::TYPE_VIEW_POLICY: $object->setViewPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_EDIT_POLICY: $object->setEditPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_JOIN_POLICY: $object->setJoinPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->applyApplicationTransactionInternalEffects($xaction); case PhabricatorTransactions::TYPE_INLINESTATE: return $this->applyBuiltinInternalTransaction($object, $xaction); } return $this->applyCustomInternalTransaction($object, $xaction); } private function applyExternalEffects( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_BUILDABLE: case PhabricatorTransactions::TYPE_TOKEN: return; case PhabricatorTransactions::TYPE_SUBSCRIBERS: $subeditor = id(new PhabricatorSubscriptionsEditor()) ->setObject($object) ->setActor($this->requireActor()); $old_map = array_fuse($xaction->getOldValue()); $new_map = array_fuse($xaction->getNewValue()); $subeditor->unsubscribe( array_keys( array_diff_key($old_map, $new_map))); $subeditor->subscribeExplicit( array_keys( array_diff_key($new_map, $old_map))); $subeditor->save(); // for the rest of these edits, subscribers should include those just // added as well as those just removed. $subscribers = array_unique(array_merge( $this->subscribers, $xaction->getOldValue(), $xaction->getNewValue())); $this->subscribers = $subscribers; break; case PhabricatorTransactions::TYPE_EDGE: if ($this->getIsInverseEdgeEditor()) { // If we're writing an inverse edge transaction, don't actually // do anything. The initiating editor on the other side of the // transaction will take care of the edge writes. break; } $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $src = $object->getPHID(); $const = $xaction->getMetadataValue('edge:type'); $type = PhabricatorEdgeType::getByConstant($const); if ($type->shouldWriteInverseTransactions()) { $this->applyInverseEdgeTransactions( $object, $xaction, $type->getInverseEdgeConstant()); } foreach ($new as $dst_phid => $edge) { $new[$dst_phid]['src'] = $src; } $editor = new PhabricatorEdgeEditor(); foreach ($old as $dst_phid => $edge) { if (!empty($new[$dst_phid])) { if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) { continue; } } $editor->removeEdge($src, $const, $dst_phid); } foreach ($new as $dst_phid => $edge) { if (!empty($old[$dst_phid])) { if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) { continue; } } $data = array( 'data' => $edge['data'], ); $editor->addEdge($src, $const, $dst_phid, $data); } $editor->save(); break; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->applyApplicationTransactionExternalEffects($xaction); case PhabricatorTransactions::TYPE_INLINESTATE: return $this->applyBuiltinExternalTransaction($object, $xaction); } return $this->applyCustomExternalTransaction($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); throw new Exception( "Transaction type '{$type}' is missing an internal apply ". "implementation!"); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); throw new Exception( "Transaction type '{$type}' is missing an external apply ". "implementation!"); } // TODO: Write proper documentation for these hooks. These are like the // "applyCustom" hooks, except that implementation is optional, so you do // not need to handle all of the builtin transaction types. See T6403. These // are not completely implemented. protected function applyBuiltinInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return; } protected function applyBuiltinExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return; } /** * Fill in a transaction's common values, like author and content source. */ protected function populateTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $actor = $this->getActor(); // TODO: This needs to be more sophisticated once we have meta-policies. $xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC); if ($actor->isOmnipotent()) { $xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); } else { $xaction->setEditPolicy($this->getActingAsPHID()); } $xaction->setAuthorPHID($this->getActingAsPHID()); $xaction->setContentSource($this->getContentSource()); $xaction->attachViewer($actor); $xaction->attachObject($object); if ($object->getPHID()) { $xaction->setObjectPHID($object->getPHID()); } return $xaction; } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { return $xactions; } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function setContentSourceFromRequest(AphrontRequest $request) { return $this->setContentSource( PhabricatorContentSource::newFromRequest($request)); } public function setContentSourceFromConduitRequest( ConduitAPIRequest $request) { $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_CONDUIT, array()); return $this->setContentSource($content_source); } public function getContentSource() { return $this->contentSource; } final public function applyTransactions( PhabricatorLiskDAO $object, array $xactions) { $this->object = $object; $this->xactions = $xactions; $this->isNewObject = ($object->getPHID() === null); $this->validateEditParameters($object, $xactions); $actor = $this->requireActor(); // NOTE: Some transaction expansion requires that the edited object be // attached. foreach ($xactions as $xaction) { $xaction->attachObject($object); $xaction->attachViewer($actor); } $xactions = $this->expandTransactions($object, $xactions); $xactions = $this->expandSupportTransactions($object, $xactions); $xactions = $this->combineTransactions($xactions); foreach ($xactions as $xaction) { $xaction = $this->populateTransaction($object, $xaction); } $is_preview = $this->getIsPreview(); $read_locking = false; $transaction_open = false; if (!$is_preview) { $errors = array(); $type_map = mgroup($xactions, 'getTransactionType'); foreach ($this->getTransactionTypes() as $type) { $type_xactions = idx($type_map, $type, array()); $errors[] = $this->validateTransaction($object, $type, $type_xactions); } $errors = array_mergev($errors); $continue_on_missing = $this->getContinueOnMissingFields(); foreach ($errors as $key => $error) { if ($continue_on_missing && $error->getIsMissingFieldError()) { unset($errors[$key]); } } if ($errors) { throw new PhabricatorApplicationTransactionValidationException($errors); } $file_phids = $this->extractFilePHIDs($object, $xactions); if ($object->getID()) { foreach ($xactions as $xaction) { // If any of the transactions require a read lock, hold one and // reload the object. We need to do this fairly early so that the // call to `adjustTransactionValues()` (which populates old values) // is based on the synchronized state of the object, which may differ // from the state when it was originally loaded. if ($this->shouldReadLock($object, $xaction)) { $object->openTransaction(); $object->beginReadLocking(); $transaction_open = true; $read_locking = true; $object->reload(); break; } } } if ($this->shouldApplyInitialEffects($object, $xactions)) { if (!$transaction_open) { $object->openTransaction(); $transaction_open = true; } } } if ($this->shouldApplyInitialEffects($object, $xactions)) { $this->applyInitialEffects($object, $xactions); } foreach ($xactions as $xaction) { $this->adjustTransactionValues($object, $xaction); } $xactions = $this->filterTransactions($object, $xactions); if (!$xactions) { if ($read_locking) { $object->endReadLocking(); $read_locking = false; } if ($transaction_open) { $object->killTransaction(); $transaction_open = false; } return array(); } // Now that we've merged, filtered, and combined transactions, check for // required capabilities. foreach ($xactions as $xaction) { $this->requireCapabilities($object, $xaction); } $xactions = $this->sortTransactions($xactions); if ($is_preview) { $this->loadHandles($xactions); return $xactions; } $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($actor) ->setActingAsPHID($this->getActingAsPHID()) ->setContentSource($this->getContentSource()); if (!$transaction_open) { $object->openTransaction(); } foreach ($xactions as $xaction) { $this->applyInternalEffects($object, $xaction); } $object->save(); foreach ($xactions as $xaction) { $xaction->setObjectPHID($object->getPHID()); if ($xaction->getComment()) { $xaction->setPHID($xaction->generatePHID()); $comment_editor->applyEdit($xaction, $xaction->getComment()); } else { $xaction->save(); } } if ($file_phids) { $this->attachFiles($object, $file_phids); } foreach ($xactions as $xaction) { $this->applyExternalEffects($object, $xaction); } $xactions = $this->applyFinalEffects($object, $xactions); if ($read_locking) { $object->endReadLocking(); $read_locking = false; } $object->saveTransaction(); // Now that we've completely applied the core transaction set, try to apply // Herald rules. Herald rules are allowed to either take direct actions on // the database (like writing flags), or take indirect actions (like saving // some targets for CC when we generate mail a little later), or return // transactions which we'll apply normally using another Editor. // First, check if *this* is a sub-editor which is itself applying Herald // rules: if it is, stop working and return so we don't descend into // madness. // Otherwise, we're not a Herald editor, so process Herald rules (possibly // using a Herald editor to apply resulting transactions) and then send out // mail, notifications, and feed updates about everything. if ($this->getIsHeraldEditor()) { // We are the Herald editor, so stop work here and return the updated // transactions. return $xactions; } else if ($this->getIsInverseEdgeEditor()) { // If we're applying inverse edge transactions, don't trigger Herald. // From a product perspective, the current set of inverse edges (most // often, mentions) aren't things users would expect to trigger Herald. // From a technical perspective, objects loaded by the inverse editor may // not have enough data to execute rules. At least for now, just stop // Herald from executing when applying inverse edges. } else if ($this->shouldApplyHeraldRules($object, $xactions)) { // We are not the Herald editor, so try to apply Herald rules. $herald_xactions = $this->applyHeraldRules($object, $xactions); if ($herald_xactions) { $xscript_id = $this->getHeraldTranscript()->getID(); foreach ($herald_xactions as $herald_xaction) { $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id); } // NOTE: We're acting as the omnipotent user because rules deal with // their own policy issues. We use a synthetic author PHID (the // Herald application) as the author of record, so that transactions // will render in a reasonable way ("Herald assigned this task ..."). $herald_actor = PhabricatorUser::getOmnipotentUser(); $herald_phid = id(new PhabricatorHeraldApplication())->getPHID(); // TODO: It would be nice to give transactions a more specific source // which points at the rule which generated them. You can figure this // out from transcripts, but it would be cleaner if you didn't have to. $herald_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_HERALD, array()); $herald_editor = newv(get_class($this), array()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setParentMessageID($this->getParentMessageID()) ->setIsHeraldEditor(true) ->setActor($herald_actor) ->setActingAsPHID($herald_phid) ->setContentSource($herald_source); $herald_xactions = $herald_editor->applyTransactions( $object, $herald_xactions); // Merge the new transactions into the transaction list: we want to // send email and publish feed stories about them, too. $xactions = array_merge($xactions, $herald_xactions); } } // Before sending mail or publishing feed stories, reload the object // subscribers to pick up changes caused by Herald (or by other side effects // in various transaction phases). $this->loadSubscribers($object); $this->loadHandles($xactions); $mail = null; if (!$this->getDisableEmail()) { if ($this->shouldSendMail($object, $xactions)) { $mail = $this->sendMail($object, $xactions); } } if ($this->supportsSearch()) { id(new PhabricatorSearchIndexer()) ->queueDocumentForIndexing( $object->getPHID(), $this->getSearchContextParameter($object, $xactions)); } if ($this->shouldPublishFeedStory($object, $xactions)) { $mailed = array(); if ($mail) { $mailed = $mail->buildRecipientList(); } $this->publishFeedStory( $object, $xactions, $mailed); } $this->didApplyTransactions($xactions); if ($object instanceof PhabricatorCustomFieldInterface) { // Maybe this makes more sense to move into the search index itself? For // now I'm putting it here since I think we might end up with things that // need it to be up to date once the next page loads, but if we don't go // there we we could move it into search once search moves to the daemons. // It now happens in the search indexer as well, but the search indexer is // always daemonized, so the logic above still potentially holds. We could // possibly get rid of this. The major motivation for putting it in the // indexer was to enable reindexing to work. $fields = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); $fields->readFieldsFromStorage($object); $fields->rebuildIndexes($object); } return $xactions; } protected function didApplyTransactions(array $xactions) { // Hook for subclasses. return; } /** * Determine if the editor should hold a read lock on the object while * applying a transaction. * * If the editor does not hold a lock, two editors may read an object at the * same time, then apply their changes without any synchronization. For most * transactions, this does not matter much. However, it is important for some * transactions. For example, if an object has a transaction count on it, both * editors may read the object with `count = 23`, then independently update it * and save the object with `count = 24` twice. This will produce the wrong * state: the object really has 25 transactions, but the count is only 24. * * Generally, transactions fall into one of four buckets: * * - Append operations: Actions like adding a comment to an object purely * add information to its state, and do not depend on the current object * state in any way. These transactions never need to hold locks. * - Overwrite operations: Actions like changing the title or description * of an object replace the current value with a new value, so the end * state is consistent without a lock. We currently do not lock these * transactions, although we may in the future. * - Edge operations: Edge and subscription operations have internal * synchronization which limits the damage race conditions can cause. * We do not currently lock these transactions, although we may in the * future. * - Update operations: Actions like incrementing a count on an object. * These operations generally should use locks, unless it is not * important that the state remain consistent in the presence of races. * * @param PhabricatorLiskDAO Object being updated. * @param PhabricatorApplicationTransaction Transaction being applied. * @return bool True to synchronize the edit with a lock. */ protected function shouldReadLock( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return false; } private function loadHandles(array $xactions) { $phids = array(); foreach ($xactions as $key => $xaction) { $phids[$key] = $xaction->getRequiredHandlePHIDs(); } $handles = array(); $merged = array_mergev($phids); if ($merged) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($merged) ->execute(); } foreach ($xactions as $key => $xaction) { $xaction->setHandles(array_select_keys($handles, $phids[$key])); } } private function loadSubscribers(PhabricatorLiskDAO $object) { if ($object->getPHID() && ($object instanceof PhabricatorSubscribableInterface)) { $subs = PhabricatorSubscribersQuery::loadSubscribersForPHID( $object->getPHID()); $this->subscribers = array_fuse($subs); } else { $this->subscribers = array(); } } private function validateEditParameters( PhabricatorLiskDAO $object, array $xactions) { if (!$this->getContentSource()) { throw new Exception( 'Call setContentSource() before applyTransactions()!'); } // Do a bunch of sanity checks that the incoming transactions are fresh. // They should be unsaved and have only "transactionType" and "newValue" // set. $types = array_fill_keys($this->getTransactionTypes(), true); assert_instances_of($xactions, 'PhabricatorApplicationTransaction'); foreach ($xactions as $xaction) { if ($xaction->getPHID() || $xaction->getID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have IDs/PHIDs!')); } if ($xaction->getObjectPHID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have objectPHIDs!')); } if ($xaction->getAuthorPHID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have authorPHIDs!')); } if ($xaction->getCommentPHID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have '. 'commentPHIDs!')); } if ($xaction->getCommentVersion() !== 0) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have '. 'commentVersions!')); } $expect_value = !$xaction->shouldGenerateOldValue(); $has_value = $xaction->hasOldValue(); if ($expect_value && !$has_value) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'This transaction is supposed to have an oldValue set, but '. 'it does not!')); } if ($has_value && !$expect_value) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'This transaction should generate its oldValue automatically, '. 'but has already had one set!')); } $type = $xaction->getTransactionType(); if (empty($types[$type])) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'Transaction has type "%s", but that transaction type is not '. 'supported by this editor (%s).', $type, get_class($this))); } } } protected function requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { if ($this->getIsNewObject()) { return; } $actor = $this->requireActor(); switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COMMENT: PhabricatorPolicyFilter::requireCapability( $actor, $object, PhabricatorPolicyCapability::CAN_VIEW); break; case PhabricatorTransactions::TYPE_VIEW_POLICY: PhabricatorPolicyFilter::requireCapability( $actor, $object, PhabricatorPolicyCapability::CAN_EDIT); break; case PhabricatorTransactions::TYPE_EDIT_POLICY: PhabricatorPolicyFilter::requireCapability( $actor, $object, PhabricatorPolicyCapability::CAN_EDIT); break; case PhabricatorTransactions::TYPE_JOIN_POLICY: PhabricatorPolicyFilter::requireCapability( $actor, $object, PhabricatorPolicyCapability::CAN_EDIT); break; } } private function buildSubscribeTransaction( PhabricatorLiskDAO $object, array $xactions, array $blocks) { if (!($object instanceof PhabricatorSubscribableInterface)) { return null; } if ($this->shouldEnableMentions($object, $xactions)) { $texts = array_mergev($blocks); $phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( $this->getActor(), $texts); } else { $phids = array(); } $this->mentionedPHIDs = $phids; if ($object->getPHID()) { // Don't try to subscribe already-subscribed mentions: we want to generate // a dialog about an action having no effect if the user explicitly adds // existing CCs, but not if they merely mention existing subscribers. $phids = array_diff($phids, $this->subscribers); } if ($phids) { $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->getActor()) ->withPHIDs($phids) ->execute(); $users = mpull($users, null, 'getPHID'); foreach ($phids as $key => $phid) { // Do not subscribe mentioned users // who do not have VIEW Permissions if ($object instanceof PhabricatorPolicyInterface && !PhabricatorPolicyFilter::hasCapability( $users[$phid], $object, PhabricatorPolicyCapability::CAN_VIEW) ) { unset($phids[$key]); } else { if ($object->isAutomaticallySubscribed($phid)) { unset($phids[$key]); } } } $phids = array_values($phids); } // No else here to properly return null should we unset all subscriber if (!$phids) { return null; } $xaction = newv(get_class(head($xactions)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS); $xaction->setNewValue(array('+' => $phids)); return $xaction; } protected function getRemarkupBlocksFromTransaction( PhabricatorApplicationTransaction $transaction) { return $transaction->getRemarkupBlocks(); } protected function mergeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $type = $u->getTransactionType(); switch ($type) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: return $this->mergePHIDOrEdgeTransactions($u, $v); case PhabricatorTransactions::TYPE_EDGE: $u_type = $u->getMetadataValue('edge:type'); $v_type = $v->getMetadataValue('edge:type'); if ($u_type == $v_type) { return $this->mergePHIDOrEdgeTransactions($u, $v); } return null; } // By default, do not merge the transactions. return null; } /** * Optionally expand transactions which imply other effects. For example, * resigning from a revision in Differential implies removing yourself as * a reviewer. */ private function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $results = array(); foreach ($xactions as $xaction) { foreach ($this->expandTransaction($object, $xaction) as $expanded) { $results[] = $expanded; } } return $results; } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return array($xaction); } public function getExpandedSupportTransactions( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $xactions = array($xaction); $xactions = $this->expandSupportTransactions( $object, $xactions); if (count($xactions) == 1) { return array(); } foreach ($xactions as $index => $cxaction) { if ($cxaction === $xaction) { unset($xactions[$index]); break; } } return $xactions; } private function expandSupportTransactions( PhabricatorLiskDAO $object, array $xactions) { $this->loadSubscribers($object); $xactions = $this->applyImplicitCC($object, $xactions); $blocks = array(); foreach ($xactions as $key => $xaction) { $blocks[$key] = $this->getRemarkupBlocksFromTransaction($xaction); } $subscribe_xaction = $this->buildSubscribeTransaction( $object, $xactions, $blocks); if ($subscribe_xaction) { $xactions[] = $subscribe_xaction; } // TODO: For now, this is just a placeholder. $engine = PhabricatorMarkupEngine::getEngine('extract'); $engine->setConfig('viewer', $this->requireActor()); $block_xactions = $this->expandRemarkupBlockTransactions( $object, $xactions, $blocks, $engine); foreach ($block_xactions as $xaction) { $xactions[] = $xaction; } return $xactions; } private function expandRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, $blocks, PhutilMarkupEngine $engine) { $block_xactions = $this->expandCustomRemarkupBlockTransactions( $object, $xactions, $blocks, $engine); $mentioned_phids = array(); if ($this->shouldEnableMentions($object, $xactions)) { foreach ($blocks as $key => $xaction_blocks) { foreach ($xaction_blocks as $block) { $engine->markupText($block); $mentioned_phids += $engine->getTextMetadata( PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS, array()); } } } if (!$mentioned_phids) { return $block_xactions; } $mentioned_objects = id(new PhabricatorObjectQuery()) ->setViewer($this->getActor()) ->withPHIDs($mentioned_phids) ->execute(); $mentionable_phids = array(); if ($this->shouldEnableMentions($object, $xactions)) { foreach ($mentioned_objects as $mentioned_object) { if ($mentioned_object instanceof PhabricatorMentionableInterface) { $mentioned_phid = $mentioned_object->getPHID(); if (idx($this->getUnmentionablePHIDMap(), $mentioned_phid)) { continue; } // don't let objects mention themselves if ($object->getPHID() && $mentioned_phid == $object->getPHID()) { continue; } $mentionable_phids[$mentioned_phid] = $mentioned_phid; } } } if ($mentionable_phids) { $edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST; $block_xactions[] = newv(get_class(head($xactions)), array()) ->setIgnoreOnNoEffect(true) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $edge_type) ->setNewValue(array('+' => $mentionable_phids)); } return $block_xactions; } protected function expandCustomRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, $blocks, PhutilMarkupEngine $engine) { return array(); } /** * Attempt to combine similar transactions into a smaller number of total * transactions. For example, two transactions which edit the title of an * object can be merged into a single edit. */ private function combineTransactions(array $xactions) { $stray_comments = array(); $result = array(); $types = array(); foreach ($xactions as $key => $xaction) { $type = $xaction->getTransactionType(); if (isset($types[$type])) { foreach ($types[$type] as $other_key) { $merged = $this->mergeTransactions($result[$other_key], $xaction); if ($merged) { $result[$other_key] = $merged; if ($xaction->getComment() && ($xaction->getComment() !== $merged->getComment())) { $stray_comments[] = $xaction->getComment(); } if ($result[$other_key]->getComment() && ($result[$other_key]->getComment() !== $merged->getComment())) { $stray_comments[] = $result[$other_key]->getComment(); } // Move on to the next transaction. continue 2; } } } $result[$key] = $xaction; $types[$type][] = $key; } // If we merged any comments away, restore them. foreach ($stray_comments as $comment) { $xaction = newv(get_class(head($result)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT); $xaction->setComment($comment); $result[] = $xaction; } return array_values($result); } protected function mergePHIDOrEdgeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $result = $u->getNewValue(); foreach ($v->getNewValue() as $key => $value) { if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) { if (empty($result[$key])) { $result[$key] = $value; } else { // We're merging two lists of edge adds, sets, or removes. Merge // them by merging individual PHIDs within them. $merged = $result[$key]; foreach ($value as $dst => $v_spec) { if (empty($merged[$dst])) { $merged[$dst] = $v_spec; } else { // Two transactions are trying to perform the same operation on // the same edge. Normalize the edge data and then merge it. This // allows transactions to specify how data merges execute in a // precise way. $u_spec = $merged[$dst]; if (!is_array($u_spec)) { $u_spec = array('dst' => $u_spec); } if (!is_array($v_spec)) { $v_spec = array('dst' => $v_spec); } $ux_data = idx($u_spec, 'data', array()); $vx_data = idx($v_spec, 'data', array()); $merged_data = $this->mergeEdgeData( $u->getMetadataValue('edge:type'), $ux_data, $vx_data); $u_spec['data'] = $merged_data; $merged[$dst] = $u_spec; } } $result[$key] = $merged; } } else { $result[$key] = array_merge($value, idx($result, $key, array())); } } $u->setNewValue($result); // When combining an "ignore" transaction with a normal transaction, make // sure we don't propagate the "ignore" flag. if (!$v->getIgnoreOnNoEffect()) { $u->setIgnoreOnNoEffect(false); } return $u; } protected function mergeEdgeData($type, array $u, array $v) { return $v + $u; } protected function getPHIDTransactionNewValue( PhabricatorApplicationTransaction $xaction, $old = null) { if ($old !== null) { $old = array_fuse($old); } else { $old = array_fuse($xaction->getOldValue()); } $new = $xaction->getNewValue(); $new_add = idx($new, '+', array()); unset($new['+']); $new_rem = idx($new, '-', array()); unset($new['-']); $new_set = idx($new, '=', null); if ($new_set !== null) { $new_set = array_fuse($new_set); } unset($new['=']); if ($new) { throw new Exception( "Invalid 'new' value for PHID transaction. Value should contain only ". "keys '+' (add PHIDs), '-' (remove PHIDs) and '=' (set PHIDS)."); } $result = array(); foreach ($old as $phid) { if ($new_set !== null && empty($new_set[$phid])) { continue; } $result[$phid] = $phid; } if ($new_set !== null) { foreach ($new_set as $phid) { $result[$phid] = $phid; } } foreach ($new_add as $phid) { $result[$phid] = $phid; } foreach ($new_rem as $phid) { unset($result[$phid]); } return array_values($result); } protected function getEdgeTransactionNewValue( PhabricatorApplicationTransaction $xaction) { $new = $xaction->getNewValue(); $new_add = idx($new, '+', array()); unset($new['+']); $new_rem = idx($new, '-', array()); unset($new['-']); $new_set = idx($new, '=', null); unset($new['=']); if ($new) { throw new Exception( "Invalid 'new' value for Edge transaction. Value should contain only ". "keys '+' (add edges), '-' (remove edges) and '=' (set edges)."); } $old = $xaction->getOldValue(); $lists = array($new_set, $new_add, $new_rem); foreach ($lists as $list) { $this->checkEdgeList($list); } $result = array(); foreach ($old as $dst_phid => $edge) { if ($new_set !== null && empty($new_set[$dst_phid])) { continue; } $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } if ($new_set !== null) { foreach ($new_set as $dst_phid => $edge) { $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } } foreach ($new_add as $dst_phid => $edge) { $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } foreach ($new_rem as $dst_phid => $edge) { unset($result[$dst_phid]); } return $result; } private function checkEdgeList($list) { if (!$list) { return; } foreach ($list as $key => $item) { if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) { throw new Exception( "Edge transactions must have destination PHIDs as in edge ". "lists (found key '{$key}')."); } if (!is_array($item) && $item !== $key) { throw new Exception( "Edge transactions must have PHIDs or edge specs as values ". "(found value '{$item}')."); } } } private function normalizeEdgeTransactionValue( PhabricatorApplicationTransaction $xaction, $edge, $dst_phid) { if (!is_array($edge)) { if ($edge != $dst_phid) { throw new Exception( pht( 'Transaction edge data must either be the edge PHID or an edge '. 'specification dictionary.')); } $edge = array(); } else { foreach ($edge as $key => $value) { switch ($key) { case 'src': case 'dst': case 'type': case 'data': case 'dateCreated': case 'dateModified': case 'seq': case 'dataID': break; default: throw new Exception( pht( 'Transaction edge specification contains unexpected key '. '"%s".', $key)); } } } $edge['dst'] = $dst_phid; $edge_type = $xaction->getMetadataValue('edge:type'); if (empty($edge['type'])) { $edge['type'] = $edge_type; } else { if ($edge['type'] != $edge_type) { $this_type = $edge['type']; throw new Exception( "Edge transaction includes edge of type '{$this_type}', but ". "transaction is of type '{$edge_type}'. Each edge transaction must ". "alter edges of only one type."); } } if (!isset($edge['data'])) { $edge['data'] = array(); } return $edge; } protected function sortTransactions(array $xactions) { $head = array(); $tail = array(); // Move bare comments to the end, so the actions precede them. foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); if ($type == PhabricatorTransactions::TYPE_COMMENT) { $tail[] = $xaction; } else { $head[] = $xaction; } } return array_values(array_merge($head, $tail)); } protected function filterTransactions( PhabricatorLiskDAO $object, array $xactions) { $type_comment = PhabricatorTransactions::TYPE_COMMENT; $no_effect = array(); $has_comment = false; $any_effect = false; foreach ($xactions as $key => $xaction) { if ($this->transactionHasEffect($object, $xaction)) { if ($xaction->getTransactionType() != $type_comment) { $any_effect = true; } } else if ($xaction->getIgnoreOnNoEffect()) { unset($xactions[$key]); } else { $no_effect[$key] = $xaction; } if ($xaction->hasComment()) { $has_comment = true; } } if (!$no_effect) { return $xactions; } if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) { throw new PhabricatorApplicationTransactionNoEffectException( $no_effect, $any_effect, $has_comment); } if (!$any_effect && !$has_comment) { // If we only have empty comment transactions, just drop them all. return array(); } foreach ($no_effect as $key => $xaction) { if ($xaction->getComment()) { $xaction->setTransactionType($type_comment); $xaction->setOldValue(null); $xaction->setNewValue(null); } else { unset($xactions[$key]); } } return $xactions; } /** * Hook for validating transactions. This callback will be invoked for each * available transaction type, even if an edit does not apply any transactions * of that type. This allows you to raise exceptions when required fields are * missing, by detecting that the object has no field value and there is no * transaction which sets one. * * @param PhabricatorLiskDAO Object being edited. * @param string Transaction type to validate. * @param list<PhabricatorApplicationTransaction> Transactions of given type, * which may be empty if the edit does not apply any transactions of the * given type. * @return list<PhabricatorApplicationTransactionValidationError> List of * validation errors. */ protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = array(); switch ($type) { case PhabricatorTransactions::TYPE_VIEW_POLICY: $errors[] = $this->validatePolicyTransaction( $object, $xactions, $type, PhabricatorPolicyCapability::CAN_VIEW); break; case PhabricatorTransactions::TYPE_EDIT_POLICY: $errors[] = $this->validatePolicyTransaction( $object, $xactions, $type, PhabricatorPolicyCapability::CAN_EDIT); break; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $groups = array(); foreach ($xactions as $xaction) { $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction; } $field_list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_EDIT); $field_list->setViewer($this->getActor()); $role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS; foreach ($field_list->getFields() as $field) { if (!$field->shouldEnableForRole($role_xactions)) { continue; } $errors[] = $field->validateApplicationTransactions( $this, $type, idx($groups, $field->getFieldKey(), array())); } break; } return array_mergev($errors); } private function validatePolicyTransaction( PhabricatorLiskDAO $object, array $xactions, $transaction_type, $capability) { $actor = $this->requireActor(); $errors = array(); // Note $this->xactions is necessary; $xactions is $this->xactions of // $transaction_type $policy_object = $this->adjustObjectForPolicyChecks( $object, $this->xactions); // Make sure the user isn't editing away their ability to $capability this // object. foreach ($xactions as $xaction) { try { PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy( $actor, $policy_object, $capability, $xaction->getNewValue()); } catch (PhabricatorPolicyException $ex) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'You can not select this %s policy, because you would no longer '. 'be able to %s the object.', $capability, $capability), $xaction); } } if ($this->getIsNewObject()) { if (!$xactions) { $has_capability = PhabricatorPolicyFilter::hasCapability( $actor, $policy_object, $capability); if (!$has_capability) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht('The selected %s policy excludes you. Choose a %s policy '. 'which allows you to %s the object.', $capability, $capability, $capability)); } } } return $errors; } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { return clone $object; } /** * Check for a missing text field. * * A text field is missing if the object has no value and there are no * transactions which set a value, or if the transactions remove the value. * This method is intended to make implementing @{method:validateTransaction} * more convenient: * * $missing = $this->validateIsEmptyTextField( * $object->getName(), * $xactions); * * This will return `true` if the net effect of the object and transactions * is an empty field. * * @param wild Current field value. * @param list<PhabricatorApplicationTransaction> Transactions editing the * field. * @return bool True if the field will be an empty text field after edits. */ protected function validateIsEmptyTextField($field_value, array $xactions) { if (strlen($field_value) && empty($xactions)) { return false; } if ($xactions && strlen(last($xactions)->getNewValue())) { return false; } return true; } /* -( Implicit CCs )------------------------------------------------------- */ /** * When a user interacts with an object, we might want to add them to CC. */ final public function applyImplicitCC( PhabricatorLiskDAO $object, array $xactions) { if (!($object instanceof PhabricatorSubscribableInterface)) { // If the object isn't subscribable, we can't CC them. return $xactions; } $actor_phid = $this->getActingAsPHID(); $type_user = PhabricatorPeopleUserPHIDType::TYPECONST; if (phid_get_type($actor_phid) != $type_user) { // Transactions by application actors like Herald, Harbormaster and // Diffusion should not CC the applications. return $xactions; } if ($object->isAutomaticallySubscribed($actor_phid)) { // If they're auto-subscribed, don't CC them. return $xactions; } $should_cc = false; foreach ($xactions as $xaction) { if ($this->shouldImplyCC($object, $xaction)) { $should_cc = true; break; } } if (!$should_cc) { // Only some types of actions imply a CC (like adding a comment). return $xactions; } if ($object->getPHID()) { if (isset($this->subscribers[$actor_phid])) { // If the user is already subscribed, don't implicitly CC them. return $xactions; } $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST); $unsub = array_fuse($unsub); if (isset($unsub[$actor_phid])) { // If the user has previously unsubscribed from this object explicitly, // don't implicitly CC them. return $xactions; } } $xaction = newv(get_class(head($xactions)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS); $xaction->setNewValue(array('+' => array($actor_phid))); array_unshift($xactions, $xaction); return $xactions; } protected function shouldImplyCC( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return $xaction->isCommentTransaction(); } /* -( Sending Mail )------------------------------------------------------- */ /** * @task mail */ protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return false; } /** * @task mail */ protected function sendMail( PhabricatorLiskDAO $object, array $xactions) { // Check if any of the transactions are visible. If we don't have any // visible transactions, don't send the mail. $any_visible = false; foreach ($xactions as $xaction) { if (!$xaction->shouldHideForMail($xactions)) { $any_visible = true; break; } } if (!$any_visible) { return; } $email_to = array_filter(array_unique($this->getMailTo($object))); $email_cc = array_filter(array_unique($this->getMailCC($object))); $phids = array_merge($email_to, $email_cc); $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($phids) ->execute(); $template = $this->buildMailTemplate($object); $body = $this->buildMailBody($object, $xactions); $mail_tags = $this->getMailTags($object, $xactions); $action = $this->getMailAction($object, $xactions); $reply_handler = $this->buildReplyHandler($object); - $reply_section = $reply_handler->getReplyHandlerInstructions(); - if ($reply_section !== null) { - $body->addReplySection($reply_section); - } $body->addEmailPreferenceSection(); $template ->setFrom($this->getActingAsPHID()) ->setSubjectPrefix($this->getMailSubjectPrefix()) ->setVarySubjectPrefix('['.$action.']') ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject()) ->setRelatedPHID($object->getPHID()) ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs()) ->setMailTags($mail_tags) ->setIsBulk(true) ->setBody($body->render()) ->setHTMLBody($body->renderHTML()); foreach ($body->getAttachments() as $attachment) { $template->addAttachment($attachment); } $herald_xscript = $this->getHeraldTranscript(); if ($herald_xscript) { $herald_header = $herald_xscript->getXHeraldRulesHeader(); $herald_header = HeraldTranscript::saveXHeraldRulesHeader( $object->getPHID(), $herald_header); } else { $herald_header = HeraldTranscript::loadXHeraldRulesHeader( $object->getPHID()); } if ($herald_header) { $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)); foreach ($mails as $mail) { $mail->saveAndSend(); } $template->addTos($email_to); $template->addCCs($email_cc); 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(); } /** * @task mail */ protected function getStrongestAction( PhabricatorLiskDAO $object, array $xactions) { return last(msort($xactions, 'getActionStrength')); } /** * @task mail */ protected function buildReplyHandler(PhabricatorLiskDAO $object) { throw new Exception('Capability not supported.'); } /** * @task mail */ protected function getMailSubjectPrefix() { throw new Exception('Capability not supported.'); } /** * @task mail */ protected function getMailTags( PhabricatorLiskDAO $object, array $xactions) { $tags = array(); foreach ($xactions as $xaction) { $tags[] = $xaction->getMailTags(); } return array_mergev($tags); } /** * @task mail */ public function getMailTagsMap() { // TODO: We should move shared mail tags, like "comment", here. return array(); } /** * @task mail */ protected function getMailAction( PhabricatorLiskDAO $object, array $xactions) { return $this->getStrongestAction($object, $xactions)->getActionName(); } /** * @task mail */ protected function buildMailTemplate(PhabricatorLiskDAO $object) { throw new Exception('Capability not supported.'); } /** * @task mail */ protected function getMailTo(PhabricatorLiskDAO $object) { throw new Exception('Capability not supported.'); } /** * @task mail */ protected function getMailCC(PhabricatorLiskDAO $object) { $phids = array(); $has_support = false; if ($object instanceof PhabricatorSubscribableInterface) { $phids[] = $this->subscribers; $has_support = true; } if ($object instanceof PhabricatorProjectInterface) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); if ($project_phids) { $watcher_type = PhabricatorObjectHasWatcherEdgeType::EDGECONST; $query = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs($project_phids) ->withEdgeTypes(array($watcher_type)); $query->execute(); $watcher_phids = $query->getDestinationPHIDs(); if ($watcher_phids) { // We need to do a visibility check for all the watchers, as // watching a project is not a guarantee that you can see objects // associated with it. $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($watcher_phids) ->execute(); $watchers = array(); foreach ($users as $user) { $can_see = PhabricatorPolicyFilter::hasCapability( $user, $object, PhabricatorPolicyCapability::CAN_VIEW); if ($can_see) { $watchers[] = $user->getPHID(); } } $phids[] = $watchers; } } $has_support = true; } if (!$has_support) { throw new Exception('Capability not supported.'); } return array_mergev($phids); } /** * @task mail */ protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = new PhabricatorMetaMTAMailBody(); $body->setViewer($this->requireActor()); $this->addHeadersAndCommentsToMailBody($body, $xactions); $this->addCustomFieldsToMailBody($body, $object, $xactions); return $body; } /** * @task mail */ protected function addHeadersAndCommentsToMailBody( PhabricatorMetaMTAMailBody $body, array $xactions) { $headers = array(); $comments = array(); foreach ($xactions as $xaction) { if ($xaction->shouldHideForMail($xactions)) { continue; } $header = $xaction->getTitleForMail(); if ($header !== null) { $headers[] = $header; } $comment = $xaction->getBodyForMail(); if ($comment !== null) { $comments[] = $comment; } } $body->addRawSection(implode("\n", $headers)); foreach ($comments as $comment) { $body->addRemarkupSection($comment); } } /** * @task mail */ protected function addCustomFieldsToMailBody( PhabricatorMetaMTAMailBody $body, PhabricatorLiskDAO $object, array $xactions) { if ($object instanceof PhabricatorCustomFieldInterface) { $field_list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_TRANSACTIONMAIL); $field_list->setViewer($this->getActor()); $field_list->readFieldsFromStorage($object); foreach ($field_list->getFields() as $field) { $field->updateTransactionMailBody( $body, $this, $xactions); } } } /* -( Publishing Feed Stories )-------------------------------------------- */ /** * @task feed */ protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return false; } /** * @task feed */ protected function getFeedStoryType() { return 'PhabricatorApplicationTransactionFeedStory'; } /** * @task feed */ protected function getFeedRelatedPHIDs( PhabricatorLiskDAO $object, array $xactions) { $phids = array( $object->getPHID(), $this->getActingAsPHID(), ); if ($object instanceof PhabricatorProjectInterface) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); foreach ($project_phids as $project_phid) { $phids[] = $project_phid; } } return $phids; } /** * @task feed */ protected function getFeedNotifyPHIDs( PhabricatorLiskDAO $object, array $xactions) { return array_unique(array_merge( $this->getMailTo($object), $this->getMailCC($object))); } /** * @task feed */ protected function getFeedStoryData( PhabricatorLiskDAO $object, array $xactions) { $xactions = msort($xactions, 'getActionStrength'); $xactions = array_reverse($xactions); return array( 'objectPHID' => $object->getPHID(), 'transactionPHIDs' => mpull($xactions, 'getPHID'), ); } /** * @task feed */ protected function publishFeedStory( PhabricatorLiskDAO $object, array $xactions, array $mailed_phids) { $xactions = mfilter($xactions, 'shouldHideForFeed', true); if (!$xactions) { return; } $related_phids = $this->getFeedRelatedPHIDs($object, $xactions); $subscribed_phids = $this->getFeedNotifyPHIDs($object, $xactions); $story_type = $this->getFeedStoryType(); $story_data = $this->getFeedStoryData($object, $xactions); id(new PhabricatorFeedStoryPublisher()) ->setStoryType($story_type) ->setStoryData($story_data) ->setStoryTime(time()) ->setStoryAuthorPHID($this->getActingAsPHID()) ->setRelatedPHIDs($related_phids) ->setPrimaryObjectPHID($object->getPHID()) ->setSubscribedPHIDs($subscribed_phids) ->setMailRecipientPHIDs($mailed_phids) ->setMailTags($this->getMailTags($object, $xactions)) ->publish(); } /* -( Search Index )------------------------------------------------------- */ /** * @task search */ protected function supportsSearch() { return false; } /** * @task search */ protected function getSearchContextParameter( PhabricatorLiskDAO $object, array $xactions) { return null; } /* -( Herald Integration )-------------------------------------------------- */ protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { throw new Exception('No herald adapter specified.'); } private function setHeraldAdapter(HeraldAdapter $adapter) { $this->heraldAdapter = $adapter; return $this; } protected function getHeraldAdapter() { return $this->heraldAdapter; } private function setHeraldTranscript(HeraldTranscript $transcript) { $this->heraldTranscript = $transcript; return $this; } protected function getHeraldTranscript() { return $this->heraldTranscript; } private function applyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { $adapter = $this->buildHeraldAdapter($object, $xactions); $adapter->setContentSource($this->getContentSource()); $adapter->setIsNewObject($this->getIsNewObject()); if ($this->getApplicationEmail()) { $adapter->setApplicationEmail($this->getApplicationEmail()); } $xscript = HeraldEngine::loadAndApplyRules($adapter); $this->setHeraldAdapter($adapter); $this->setHeraldTranscript($xscript); return array_merge( $this->didApplyHeraldRules($object, $adapter, $xscript), $adapter->getQueuedTransactions()); } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { return array(); } /* -( Custom Fields )------------------------------------------------------ */ /** * @task customfield */ private function getCustomFieldForTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $field_key = $xaction->getMetadataValue('customfield:key'); if (!$field_key) { throw new Exception( "Custom field transaction has no 'customfield:key'!"); } $field = PhabricatorCustomField::getObjectField( $object, PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS, $field_key); if (!$field) { throw new Exception( "Custom field transaction has invalid 'customfield:key'; field ". "'{$field_key}' is disabled or does not exist."); } if (!$field->shouldAppearInApplicationTransactions()) { throw new Exception( "Custom field transaction '{$field_key}' does not implement ". "integration for ApplicationTransactions."); } $field->setViewer($this->getActor()); return $field; } /* -( Files )-------------------------------------------------------------- */ /** * Extract the PHIDs of any files which these transactions attach. * * @task files */ private function extractFilePHIDs( PhabricatorLiskDAO $object, array $xactions) { $blocks = array(); foreach ($xactions as $xaction) { $blocks[] = $this->getRemarkupBlocksFromTransaction($xaction); } $blocks = array_mergev($blocks); $phids = array(); if ($blocks) { $phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles( $this->getActor(), $blocks); } foreach ($xactions as $xaction) { $phids[] = $this->extractFilePHIDsFromCustomTransaction( $object, $xaction); } $phids = array_unique(array_filter(array_mergev($phids))); if (!$phids) { return array(); } // Only let a user attach files they can actually see, since this would // otherwise let you access any file by attaching it to an object you have // view permission on. $files = id(new PhabricatorFileQuery()) ->setViewer($this->getActor()) ->withPHIDs($phids) ->execute(); return mpull($files, 'getPHID'); } /** * @task files */ protected function extractFilePHIDsFromCustomTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return array(); } /** * @task files */ private function attachFiles( PhabricatorLiskDAO $object, array $file_phids) { if (!$file_phids) { return; } $editor = new PhabricatorEdgeEditor(); $src = $object->getPHID(); $type = PhabricatorObjectHasFileEdgeType::EDGECONST; foreach ($file_phids as $dst) { $editor->addEdge($src, $type, $dst); } $editor->save(); } private function applyInverseEdgeTransactions( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction, $inverse_type) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_keys(array_diff_key($new, $old)); $rem = array_keys(array_diff_key($old, $new)); $add = array_fuse($add); $rem = array_fuse($rem); $all = $add + $rem; $nodes = id(new PhabricatorObjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs($all) ->execute(); foreach ($nodes as $node) { if (!($node instanceof PhabricatorApplicationTransactionInterface)) { continue; } $editor = $node->getApplicationTransactionEditor(); $template = $node->getApplicationTransactionTemplate(); $target = $node->getApplicationTransactionObject(); if (isset($add[$node->getPHID()])) { $edge_edit_type = '+'; } else { $edge_edit_type = '-'; } $template ->setTransactionType($xaction->getTransactionType()) ->setMetadataValue('edge:type', $inverse_type) ->setNewValue( array( $edge_edit_type => array($object->getPHID() => $object->getPHID()), )); $editor ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setParentMessageID($this->getParentMessageID()) ->setIsInverseEdgeEditor(true) ->setActor($this->requireActor()) ->setActingAsPHID($this->getActingAsPHID()) ->setContentSource($this->getContentSource()); $editor->applyTransactions($target, array($template)); } } }