diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 11280bd14b..a08a4d5f4b 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1200,15 +1200,19 @@ phutil_register_library_map(array( 'PonderAnswerQuery' => 'applications/ponder/query/PonderAnswerQuery.php', 'PonderAnswerSaveController' => 'applications/ponder/controller/PonderAnswerSaveController.php', 'PonderAnswerViewController' => 'applications/ponder/controller/PonderAnswerViewController.php', + 'PonderAnsweredMail' => 'applications/ponder/mail/PonderAnsweredMail.php', 'PonderComment' => 'applications/ponder/storage/PonderComment.php', 'PonderCommentEditor' => 'applications/ponder/editor/PonderCommentEditor.php', 'PonderCommentListView' => 'applications/ponder/view/PonderCommentListView.php', + 'PonderCommentMail' => 'applications/ponder/mail/PonderCommentMail.php', 'PonderCommentQuery' => 'applications/ponder/query/PonderCommentQuery.php', 'PonderCommentSaveController' => 'applications/ponder/controller/PonderCommentSaveController.php', 'PonderConstants' => 'applications/ponder/PonderConstants.php', 'PonderController' => 'applications/ponder/controller/PonderController.php', 'PonderDAO' => 'applications/ponder/storage/PonderDAO.php', 'PonderFeedController' => 'applications/ponder/controller/PonderFeedController.php', + 'PonderMail' => 'applications/ponder/mail/PonderMail.php', + 'PonderMentionMail' => 'applications/ponder/mail/PonderMentionMail.php', 'PonderPostBodyView' => 'applications/ponder/view/PonderPostBodyView.php', 'PonderQuestion' => 'applications/ponder/storage/PonderQuestion.php', 'PonderQuestionAskController' => 'applications/ponder/controller/PonderQuestionAskController.php', @@ -1218,6 +1222,7 @@ phutil_register_library_map(array( 'PonderQuestionQuery' => 'applications/ponder/query/PonderQuestionQuery.php', 'PonderQuestionSummaryView' => 'applications/ponder/view/PonderQuestionSummaryView.php', 'PonderQuestionViewController' => 'applications/ponder/controller/PonderQuestionViewController.php', + 'PonderReplyHandler' => 'applications/ponder/PonderReplyHandler.php', 'PonderRuleQuestion' => 'infrastructure/markup/rule/PonderRuleQuestion.php', 'PonderUserProfileView' => 'applications/ponder/view/PonderUserProfileView.php', 'PonderVotableInterface' => 'applications/ponder/storage/PonderVotableInterface.php', @@ -2318,23 +2323,27 @@ phutil_register_library_map(array( 'PonderAnswerQuery' => 'PhabricatorOffsetPagedQuery', 'PonderAnswerSaveController' => 'PonderController', 'PonderAnswerViewController' => 'PonderController', + 'PonderAnsweredMail' => 'PonderMail', 'PonderComment' => array( 0 => 'PonderDAO', 1 => 'PhabricatorMarkupInterface', ), 'PonderCommentListView' => 'AphrontView', + 'PonderCommentMail' => 'PonderMail', 'PonderCommentQuery' => 'PhabricatorQuery', 'PonderCommentSaveController' => 'PonderController', 'PonderController' => 'PhabricatorController', 'PonderDAO' => 'PhabricatorLiskDAO', 'PonderFeedController' => 'PonderController', + 'PonderMentionMail' => 'PonderMail', 'PonderPostBodyView' => 'AphrontView', 'PonderQuestion' => array( 0 => 'PonderDAO', 1 => 'PhabricatorMarkupInterface', 2 => 'PonderVotableInterface', + 3 => 'PhabricatorSubscribableInterface', ), 'PonderQuestionAskController' => 'PonderController', 'PonderQuestionDetailView' => 'AphrontView', @@ -2342,6 +2351,7 @@ phutil_register_library_map(array( 'PonderQuestionQuery' => 'PhabricatorOffsetPagedQuery', 'PonderQuestionSummaryView' => 'AphrontView', 'PonderQuestionViewController' => 'PonderController', + 'PonderReplyHandler' => 'PhabricatorMailReplyHandler', 'PonderRuleQuestion' => 'PhabricatorRemarkupRuleObjectName', 'PonderUserProfileView' => 'AphrontView', 'PonderVotableView' => 'AphrontView', diff --git a/src/applications/ponder/PonderReplyHandler.php b/src/applications/ponder/PonderReplyHandler.php new file mode 100644 index 0000000000..38aff689c6 --- /dev/null +++ b/src/applications/ponder/PonderReplyHandler.php @@ -0,0 +1,48 @@ +getDefaultPrivateReplyHandlerEmailAddress($handle, 'Q'); + } + + public function getPublicReplyHandlerEmailAddress() { + return $this->getDefaultPublicReplyHandlerEmailAddress('Q'); + } + + public function getReplyHandlerDomain() { + return PhabricatorEnv::getEnvConfig( + 'metamta.maniphest.reply-handler-domain'); + } + + public function getReplyHandlerInstructions() { + return null; + } + + protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { + // ignore this entirely for now + } +} diff --git a/src/applications/ponder/controller/PonderAnswerSaveController.php b/src/applications/ponder/controller/PonderAnswerSaveController.php index 315b8a7774..00f0e61a4d 100644 --- a/src/applications/ponder/controller/PonderAnswerSaveController.php +++ b/src/applications/ponder/controller/PonderAnswerSaveController.php @@ -60,6 +60,7 @@ final class PonderAnswerSaveController extends PonderController { ->setContentSource($content_source); id(new PonderAnswerEditor()) + ->setUser($user) ->setQuestion($question) ->setAnswer($res) ->saveAnswer(); diff --git a/src/applications/ponder/controller/PonderCommentSaveController.php b/src/applications/ponder/controller/PonderCommentSaveController.php index 2a254171a2..5c1688e131 100644 --- a/src/applications/ponder/controller/PonderCommentSaveController.php +++ b/src/applications/ponder/controller/PonderCommentSaveController.php @@ -38,6 +38,7 @@ final class PonderCommentSaveController extends PonderController { if (!$objects) { return new Aphront404Response(); } + $content = $request->getStr('content'); if (!strlen(trim($content))) { @@ -59,6 +60,8 @@ final class PonderCommentSaveController extends PonderController { id(new PonderCommentEditor()) ->setQuestion($question) ->setComment($res) + ->setTargetPHID($target) + ->setUser($user) ->save(); return id(new AphrontRedirectResponse()) diff --git a/src/applications/ponder/controller/PonderQuestionAskController.php b/src/applications/ponder/controller/PonderQuestionAskController.php index 54ea36e215..5f1d59461a 100644 --- a/src/applications/ponder/controller/PonderQuestionAskController.php +++ b/src/applications/ponder/controller/PonderQuestionAskController.php @@ -53,6 +53,7 @@ final class PonderQuestionAskController extends PonderController { id(new PonderQuestionEditor()) ->setQuestion($question) + ->setUser($user) ->save(); return id(new AphrontRedirectResponse()) diff --git a/src/applications/ponder/controller/PonderQuestionViewController.php b/src/applications/ponder/controller/PonderQuestionViewController.php index 23d48aa3a5..6bfef7d023 100644 --- a/src/applications/ponder/controller/PonderQuestionViewController.php +++ b/src/applications/ponder/controller/PonderQuestionViewController.php @@ -52,6 +52,11 @@ final class PonderQuestionViewController extends PonderController { } } + $subscribers = PhabricatorSubscribersQuery::loadSubscribersForPHID( + $question->getPHID()); + + $object_phids = array_merge($object_phids, $subscribers); + $handles = $this->loadViewerHandles($object_phids); $this->loadHandles($object_phids); @@ -79,7 +84,7 @@ final class PonderQuestionViewController extends PonderController { ->setHeader($question->getTitle()); $actions = $this->buildActionListView($question); - $properties = $this->buildPropertyListView($question); + $properties = $this->buildPropertyListView($question, $subscribers); $nav = $this->buildSideNavView($question); $nav->appendChild( @@ -112,7 +117,10 @@ final class PonderQuestionViewController extends PonderController { return $view; } - private function buildPropertyListView(PonderQuestion $question) { + private function buildPropertyListView( + PonderQuestion $question, + array $subscribers) { + $viewer = $this->getRequest()->getUser(); $view = new PhabricatorPropertyListView(); @@ -124,6 +132,17 @@ final class PonderQuestionViewController extends PonderController { pht('Created'), phabricator_datetime($question->getDateCreated(), $viewer)); + if ($subscribers) { + foreach ($subscribers as $key => $subscriber) { + $subscribers[$key] = $this->getHandle($subscriber)->renderLink(); + } + $subscribers = implode(', ', $subscribers); + } + + $view->addProperty( + pht('Subscribers'), + nonempty($subscribers, ''.pht('None').'')); + return $view; } } diff --git a/src/applications/ponder/editor/PonderAnswerEditor.php b/src/applications/ponder/editor/PonderAnswerEditor.php index 199c9102d8..9225873da8 100644 --- a/src/applications/ponder/editor/PonderAnswerEditor.php +++ b/src/applications/ponder/editor/PonderAnswerEditor.php @@ -16,11 +16,11 @@ * limitations under the License. */ - final class PonderAnswerEditor { - private $question; private $answer; + private $viewer; + private $shouldEmail = true; public function setQuestion($question) { $this->question = $question; @@ -32,7 +32,15 @@ final class PonderAnswerEditor { return $this; } + public function setUser(PhabricatorUser $user) { + $this->viewer = $user; + return $this; + } + public function saveAnswer() { + if (!$this->viewer) { + throw new Exception("Must set user before saving question"); + } if (!$this->question) { throw new Exception("Must set question before saving answer"); } @@ -42,6 +50,7 @@ final class PonderAnswerEditor { $question = $this->question; $answer = $this->answer; + $viewer = $this->viewer; $conn = $answer->establishConnection('w'); $trans = $conn->openTransaction(); $trans->beginReadLocking(); @@ -63,5 +72,53 @@ final class PonderAnswerEditor { $question->attachRelated(); PhabricatorSearchPonderIndexer::indexQuestion($question); + + // subscribe author and @mentions + $subeditor = id(new PhabricatorSubscriptionsEditor()) + ->setObject($question) + ->setUser($viewer); + + $subeditor->subscribeExplicit(array($answer->getAuthorPHID())); + + $content = $answer->getContent(); + $at_mention_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( + array($content) + ); + $subeditor->subscribeImplicit($at_mention_phids); + $subeditor->save(); + + if ($this->shouldEmail) { + // now load subscribers, including implicitly-added @mention victims + $subscribers = PhabricatorSubscribersQuery + ::loadSubscribersForPHID($question->getPHID()); + + + // @mention emails (but not for anyone who has explicitly unsubscribed) + if (array_intersect($at_mention_phids, $subscribers)) { + id(new PonderMentionMail( + $question, + $answer, + $viewer)) + ->setToPHIDs($at_mention_phids) + ->send(); + } + + $other_subs = + array_diff( + $subscribers, + $at_mention_phids + ); + + // 'Answered' emails for subscribers who are not @mentiond (and excluding + // author depending on their MetaMTA settings). + if ($other_subs) { + id(new PonderAnsweredMail( + $question, + $answer, + $viewer)) + ->setToPHIDs($other_subs) + ->send(); + } + } } } diff --git a/src/applications/ponder/editor/PonderCommentEditor.php b/src/applications/ponder/editor/PonderCommentEditor.php index 4a69f8ec2d..f51bcb9e0c 100644 --- a/src/applications/ponder/editor/PonderCommentEditor.php +++ b/src/applications/ponder/editor/PonderCommentEditor.php @@ -21,6 +21,9 @@ final class PonderCommentEditor { private $question; private $comment; + private $targetPHID; + private $viewer; + private $shouldEmail = true; public function setComment(PonderComment $comment) { $this->comment = $comment; @@ -32,6 +35,16 @@ final class PonderCommentEditor { return $this; } + public function setTargetPHID($target) { + $this->targetPHID = $target; + return $this; + } + + public function setUser(PhabricatorUser $user) { + $this->viewer = $user; + return $this; + } + public function save() { if (!$this->comment) { throw new Exception("Must set comment before saving it"); @@ -39,13 +52,82 @@ final class PonderCommentEditor { if (!$this->question) { throw new Exception("Must set question before saving comment"); } + if (!$this->targetPHID) { + throw new Exception("Must set target before saving comment"); + } + if (!$this->viewer) { + throw new Exception("Must set viewer before saving comment"); + } $comment = $this->comment; $question = $this->question; - + $target = $this->targetPHID; + $viewer = $this->viewer; $comment->save(); $question->attachRelated(); PhabricatorSearchPonderIndexer::indexQuestion($question); + + // subscribe author and @mentions + $subeditor = id(new PhabricatorSubscriptionsEditor()) + ->setObject($question) + ->setUser($viewer); + + $subeditor->subscribeExplicit(array($comment->getAuthorPHID())); + + $content = $comment->getContent(); + $at_mention_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( + array($content) + ); + $subeditor->subscribeImplicit($at_mention_phids); + $subeditor->save(); + + if ($this->shouldEmail) { + // now load subscribers, including implicitly-added @mention victims + $subscribers = PhabricatorSubscribersQuery + ::loadSubscribersForPHID($question->getPHID()); + + // @mention emails (but not for anyone who has explicitly unsubscribed) + if (array_intersect($at_mention_phids, $subscribers)) { + id(new PonderMentionMail( + $question, + $comment, + $viewer)) + ->setToPHIDs($at_mention_phids) + ->send(); + } + + if ($target === $question->getPHID()) { + $target = $question; + } + else { + $answers_by_phid = mgroup($question->getAnswers(), 'getPHID'); + $target = head($answers_by_phid[$target]); + } + + // only send emails to others in the same thread + $thread = mpull($target->getComments(), 'getAuthorPHID'); + $thread[] = $target->getAuthorPHID(); + $thread[] = $question->getAuthorPHID(); + + $other_subs = + array_diff( + array_intersect($thread, $subscribers), + $at_mention_phids + ); + + // 'Comment' emails for subscribers who are in the same comment thread, + // including the author of the parent question and/or answer, excluding + // @mentions (and excluding the author, depending on their MetaMTA + // settings). + if ($other_subs) { + id(new PonderCommentMail( + $question, + $comment, + $viewer)) + ->setToPHIDs($other_subs) + ->send(); + } + } } } diff --git a/src/applications/ponder/editor/PonderQuestionEditor.php b/src/applications/ponder/editor/PonderQuestionEditor.php index 1fc3b7c231..08ec5e5236 100644 --- a/src/applications/ponder/editor/PonderQuestionEditor.php +++ b/src/applications/ponder/editor/PonderQuestionEditor.php @@ -21,21 +21,60 @@ final class PonderQuestionEditor { private $question; private $viewer; + private $shouldEmail = true; public function setQuestion(PonderQuestion $question) { $this->question = $question; return $this; } + public function setUser(PhabricatorUser $user) { + $this->viewer = $user; + return $this; + } + + public function setShouldEmail($se) { + $this->shouldEmail = $se; + return $this; + } + public function save() { + if (!$this->viewer) { + throw new Exception("Must set user before saving question"); + } if (!$this->question) { throw new Exception("Must set question before saving it"); } + $viewer = $this->viewer; $question = $this->question; $question->save(); + // search index $question->attachRelated(); PhabricatorSearchPonderIndexer::indexQuestion($question); + + // subscribe author and @mentions + $subeditor = id(new PhabricatorSubscriptionsEditor()) + ->setObject($question) + ->setUser($viewer); + + $subeditor->subscribeExplicit(array($question->getAuthorPHID())); + + $content = $question->getContent(); + $at_mention_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( + array($content) + ); + $subeditor->subscribeImplicit($at_mention_phids); + $subeditor->save(); + + if ($this->shouldEmail && $at_mention_phids) { + id(new PonderMentionMail( + $question, + $question, + $viewer)) + ->setToPHIDs($at_mention_phids) + ->send(); + } } } diff --git a/src/applications/ponder/mail/PonderAnsweredMail.php b/src/applications/ponder/mail/PonderAnsweredMail.php new file mode 100644 index 0000000000..8c2b4ae5c5 --- /dev/null +++ b/src/applications/ponder/mail/PonderAnsweredMail.php @@ -0,0 +1,53 @@ +setQuestion($question); + $this->setTarget($target); + $this->setActorHandle($actor); + } + + protected function renderVaryPrefix() { + return "[Answered]"; + } + + protected function renderBody() { + $question = $this->getQuestion(); + $target = $this->getTarget(); + $actor = $this->getActorName(); + $name = $question->getTitle(); + + $body = array(); + $body[] = "{$actor} answered a question that you are subscribed to."; + $body[] = null; + + $content = $target->getContent(); + if (strlen($content)) { + $body[] = $this->formatText($content); + $body[] = null; + } + + return implode("\n", $body); + } +} diff --git a/src/applications/ponder/mail/PonderCommentMail.php b/src/applications/ponder/mail/PonderCommentMail.php new file mode 100644 index 0000000000..312192e412 --- /dev/null +++ b/src/applications/ponder/mail/PonderCommentMail.php @@ -0,0 +1,53 @@ +setQuestion($question); + $this->setTarget($target); + $this->setActorHandle($actor); + } + + protected function renderVaryPrefix() { + return "[Commented]"; + } + + protected function renderBody() { + $question = $this->getQuestion(); + $target = $this->getTarget(); + $actor = $this->getActorName(); + $name = $question->getTitle(); + + $body = array(); + $body[] = "{$actor} commented on a question that you are subscribed to."; + $body[] = null; + + $content = $target->getContent(); + if (strlen($content)) { + $body[] = $this->formatText($content); + $body[] = null; + } + + return implode("\n", $body); + } +} diff --git a/src/applications/ponder/mail/PonderMail.php b/src/applications/ponder/mail/PonderMail.php new file mode 100644 index 0000000000..38348ddc8d --- /dev/null +++ b/src/applications/ponder/mail/PonderMail.php @@ -0,0 +1,144 @@ +getQuestion(); + $title = $question->getTitle(); + $id = $question->getID(); + return "Q{$id}: {$title}"; + } + + abstract protected function renderVaryPrefix(); + abstract protected function renderBody(); + + public function setActorHandle($actor_handle) { + $this->actorHandle = $actor_handle; + return $this; + } + + public function getActorHandle() { + return $this->actorHandle; + } + + protected function getActorName() { + return $this->actorHandle->getRealName(); + } + + protected function getSubjectPrefix() { + return "[Ponder]"; + } + + public function setToPHIDs(array $to) { + $this->to = $to; + return $this; + } + + protected function getToPHIDs() { + return $this->to; + } + + public function setQuestion($question) { + $this->question = $question; + return $this; + } + + public function getQuestion() { + return $this->question; + } + + public function setTarget($target) { + $this->target = $target; + return $this; + } + + public function getTarget() { + return $this->target; + } + + protected function getThreadID() { + $phid = $this->getQuestion()->getPHID(); + return "ponder-ques-{$phid}"; + } + + protected function getThreadTopic() { + $id = $this->getQuestion()->getID(); + $title = $this->getQuestion()->getTitle(); + return "Q{$id}: {$title}"; + } + + public function send() { + $email_to = array_filter(array_unique($this->to)); + $question = $this->getQuestion(); + $target = $this->getTarget(); + + $uri = PhabricatorEnv::getURI('/Q'. $question->getID()); + $thread_id = $this->getThreadID(); + + $handles = id(new PhabricatorObjectHandleData($email_to)) + ->loadHandles(); + + $reply_handler = new PonderReplyHandler(); + + $body = new PhabricatorMetaMTAMailBody(); + $body->addRawSection($this->renderBody()); + $body->addTextSection(pht('QUESTION DETAIL'), $uri); + + $template = id(new PhabricatorMetaMTAMail()) + ->setSubject($this->getThreadTopic()) + ->setSubjectPrefix($this->getSubjectPrefix()) + ->setVarySubjectPrefix($this->renderVaryPrefix()) + ->setFrom($target->getAuthorPHID()) + ->setParentMessageID($this->parentMessageID) + ->addHeader('Thread-Topic', $this->getThreadTopic()) + ->setThreadID($this->getThreadID(), false) + ->setRelatedPHID($question->getPHID()) + ->setIsBulk(true) + ->setBody($body->render()); + + $mails = $reply_handler->multiplexMail( + $template, + array_select_keys($handles, $email_to), + array()); + + foreach ($mails as $mail) { + $mail->saveAndSend(); + } + } + + protected function formatText($text) { + $text = explode("\n", rtrim($text)); + foreach ($text as &$line) { + $line = rtrim(' '.$line); + } + unset($line); + return implode("\n", $text); + } +} diff --git a/src/applications/ponder/mail/PonderMentionMail.php b/src/applications/ponder/mail/PonderMentionMail.php new file mode 100644 index 0000000000..c1d3863627 --- /dev/null +++ b/src/applications/ponder/mail/PonderMentionMail.php @@ -0,0 +1,64 @@ +setQuestion($question); + $this->setTarget($target); + $this->setActorHandle($actor); + } + + protected function renderVaryPrefix() { + return "[Mentioned]"; + } + + protected function renderBody() { + $question = $this->getQuestion(); + $target = $this->getTarget(); + $actor = $this->getActorName(); + $name = $question->getTitle(); + + $targetkind = "somewhere"; + if ($target instanceof PonderQuestion) { + $targetkind = "in a question"; + } + else if ($target instanceof PonderAnswer) { + $targetkind = "in an answer"; + } + else if ($target instanceof PonderComment) { + $targetkind = "in a comment"; + } + + $body = array(); + $body[] = "{$actor} mentioned you {$targetkind}."; + $body[] = null; + + $content = $target->getContent(); + if (strlen($content)) { + $body[] = $this->formatText($content); + $body[] = null; + } + + return implode("\n", $body); + } +} diff --git a/src/applications/ponder/storage/PonderQuestion.php b/src/applications/ponder/storage/PonderQuestion.php index 6c06f175ce..d82132b4ea 100644 --- a/src/applications/ponder/storage/PonderQuestion.php +++ b/src/applications/ponder/storage/PonderQuestion.php @@ -17,7 +17,10 @@ */ final class PonderQuestion extends PonderDAO - implements PhabricatorMarkupInterface, PonderVotableInterface { + implements + PhabricatorMarkupInterface, + PonderVotableInterface, + PhabricatorSubscribableInterface { const MARKUP_FIELD_CONTENT = 'markup:content'; @@ -168,4 +171,8 @@ final class PonderQuestion extends PonderDAO public function getVotablePHID() { return $this->getPHID(); } + + public function isAutomaticallySubscribed($phid) { + return false; + } }