diff --git a/src/applications/feed/PhabricatorFeedStoryPublisher.php b/src/applications/feed/PhabricatorFeedStoryPublisher.php index 3b59edc37b..8d018c61b3 100644 --- a/src/applications/feed/PhabricatorFeedStoryPublisher.php +++ b/src/applications/feed/PhabricatorFeedStoryPublisher.php @@ -1,304 +1,305 @@ mailTags = $mail_tags; return $this; } public function getMailTags() { return $this->mailTags; } public function setNotifyAuthor($notify_author) { $this->notifyAuthor = $notify_author; return $this; } public function getNotifyAuthor() { return $this->notifyAuthor; } public function setRelatedPHIDs(array $phids) { $this->relatedPHIDs = $phids; return $this; } public function setSubscribedPHIDs(array $phids) { $this->subscribedPHIDs = $phids; return $this; } public function setPrimaryObjectPHID($phid) { $this->primaryObjectPHID = $phid; return $this; } public function setStoryType($story_type) { $this->storyType = $story_type; return $this; } public function setStoryData(array $data) { $this->storyData = $data; return $this; } public function setStoryTime($time) { $this->storyTime = $time; return $this; } public function setStoryAuthorPHID($phid) { $this->storyAuthorPHID = $phid; return $this; } public function setMailRecipientPHIDs(array $phids) { $this->mailRecipientPHIDs = $phids; return $this; } public function publish() { $class = $this->storyType; if (!$class) { throw new Exception( pht( 'Call %s before publishing!', 'setStoryType()')); } if (!class_exists($class)) { throw new Exception( pht( "Story type must be a valid class name and must subclass %s. ". "'%s' is not a loadable class.", 'PhabricatorFeedStory', $class)); } if (!is_subclass_of($class, 'PhabricatorFeedStory')) { throw new Exception( pht( "Story type must be a valid class name and must subclass %s. ". "'%s' is not a subclass of %s.", 'PhabricatorFeedStory', $class, 'PhabricatorFeedStory')); } $chrono_key = $this->generateChronologicalKey(); $story = new PhabricatorFeedStoryData(); $story->setStoryType($this->storyType); $story->setStoryData($this->storyData); $story->setAuthorPHID((string)$this->storyAuthorPHID); $story->setChronologicalKey($chrono_key); $story->save(); if ($this->relatedPHIDs) { $ref = new PhabricatorFeedStoryReference(); $sql = array(); $conn = $ref->establishConnection('w'); foreach (array_unique($this->relatedPHIDs) as $phid) { $sql[] = qsprintf( $conn, '(%s, %s)', $phid, $chrono_key); } queryfx( $conn, 'INSERT INTO %T (objectPHID, chronologicalKey) VALUES %Q', $ref->getTableName(), implode(', ', $sql)); } $subscribed_phids = $this->subscribedPHIDs; if ($subscribed_phids) { $subscribed_phids = $this->filterSubscribedPHIDs($subscribed_phids); $this->insertNotifications($chrono_key, $subscribed_phids); $this->sendNotification($chrono_key, $subscribed_phids); } PhabricatorWorker::scheduleTask( 'FeedPublisherWorker', array( 'key' => $chrono_key, )); return $story; } private function insertNotifications($chrono_key, array $subscribed_phids) { if (!$this->primaryObjectPHID) { throw new Exception( pht( 'You must call %s if you %s!', 'setPrimaryObjectPHID()', 'setSubscribedPHIDs()')); } $notif = new PhabricatorFeedStoryNotification(); $sql = array(); $conn = $notif->establishConnection('w'); $will_receive_mail = array_fill_keys($this->mailRecipientPHIDs, true); $user_phids = array_unique($subscribed_phids); foreach ($user_phids as $user_phid) { if (isset($will_receive_mail[$user_phid])) { $mark_read = 1; } else { $mark_read = 0; } $sql[] = qsprintf( $conn, '(%s, %s, %s, %d)', $this->primaryObjectPHID, $user_phid, $chrono_key, $mark_read); } if ($sql) { queryfx( $conn, 'INSERT INTO %T '. '(primaryObjectPHID, userPHID, chronologicalKey, hasViewed) '. 'VALUES %Q', $notif->getTableName(), implode(', ', $sql)); } PhabricatorUserCache::clearCaches( PhabricatorUserNotificationCountCacheType::KEY_COUNT, $user_phids); } private function sendNotification($chrono_key, array $subscribed_phids) { $data = array( 'key' => (string)$chrono_key, 'type' => 'notification', 'subscribers' => $subscribed_phids, ); PhabricatorNotificationClient::tryToPostMessage($data); } /** * Remove PHIDs who should not receive notifications from a subscriber list. * * @param list List of potential subscribers. * @return list List of actual subscribers. */ private function filterSubscribedPHIDs(array $phids) { $phids = $this->expandRecipients($phids); $tags = $this->getMailTags(); if ($tags) { $all_prefs = id(new PhabricatorUserPreferencesQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUserPHIDs($phids) + ->needSyntheticPreferences(true) ->execute(); $all_prefs = mpull($all_prefs, null, 'getUserPHID'); } $pref_default = PhabricatorEmailTagsSetting::VALUE_EMAIL; $pref_ignore = PhabricatorEmailTagsSetting::VALUE_IGNORE; $keep = array(); foreach ($phids as $phid) { if (($phid == $this->storyAuthorPHID) && !$this->getNotifyAuthor()) { continue; } if ($tags && isset($all_prefs[$phid])) { $mailtags = $all_prefs[$phid]->getSettingValue( PhabricatorEmailTagsSetting::SETTINGKEY); $notify = false; foreach ($tags as $tag) { // If this is set to "email" or "notify", notify the user. if ((int)idx($mailtags, $tag, $pref_default) != $pref_ignore) { $notify = true; break; } } if (!$notify) { continue; } } $keep[] = $phid; } return array_values(array_unique($keep)); } private function expandRecipients(array $phids) { return id(new PhabricatorMetaMTAMemberQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($phids) ->executeExpansion(); } /** * We generate a unique chronological key for each story type because we want * to be able to page through the stream with a cursor (i.e., select stories * after ID = X) so we can efficiently perform filtering after selecting data, * and multiple stories with the same ID make this cumbersome without putting * a bunch of logic in the client. We could use the primary key, but that * would prevent publishing stories which happened in the past. Since it's * potentially useful to do that (e.g., if you're importing another data * source) build a unique key for each story which has chronological ordering. * * @return string A unique, time-ordered key which identifies the story. */ private function generateChronologicalKey() { // Use the epoch timestamp for the upper 32 bits of the key. Default to // the current time if the story doesn't have an explicit timestamp. $time = nonempty($this->storyTime, time()); // Generate a random number for the lower 32 bits of the key. $rand = head(unpack('L', Filesystem::readRandomBytes(4))); // On 32-bit machines, we have to get creative. if (PHP_INT_SIZE < 8) { // We're on a 32-bit machine. if (function_exists('bcadd')) { // Try to use the 'bc' extension. return bcadd(bcmul($time, bcpow(2, 32)), $rand); } else { // Do the math in MySQL. TODO: If we formalize a bc dependency, get // rid of this. $conn_r = id(new PhabricatorFeedStoryData())->establishConnection('r'); $result = queryfx_one( $conn_r, 'SELECT (%d << 32) + %d as N', $time, $rand); return $result['N']; } } else { // This is a 64 bit machine, so we can just do the math. return ($time << 32) + $rand; } } } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php index ceea3f0429..0c90e43832 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php @@ -1,1179 +1,1171 @@ status = PhabricatorMailOutboundStatus::STATUS_QUEUE; $this->parameters = array('sensitive' => true); parent::__construct(); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'parameters' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'actorPHID' => 'phid?', 'status' => 'text32', 'relatedPHID' => 'phid?', // T6203/NULLABILITY // This should just be empty if there's no body. 'message' => 'text?', ), self::CONFIG_KEY_SCHEMA => array( 'status' => array( 'columns' => array('status'), ), 'key_actorPHID' => array( 'columns' => array('actorPHID'), ), 'relatedPHID' => array( 'columns' => array('relatedPHID'), ), 'key_created' => array( 'columns' => array('dateCreated'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorMetaMTAMailPHIDType::TYPECONST); } protected function setParam($param, $value) { $this->parameters[$param] = $value; return $this; } protected function getParam($param, $default = null) { // Some old mail was saved without parameters because no parameters were // set or encoding failed. Recover in these cases so we can perform // mail migrations, see T9251. if (!is_array($this->parameters)) { $this->parameters = array(); } return idx($this->parameters, $param, $default); } /** * These tags are used to allow users to opt out of receiving certain types * of mail, like updates when a task's projects change. * * @param list * @return this */ public function setMailTags(array $tags) { $this->setParam('mailtags', array_unique($tags)); return $this; } public function getMailTags() { return $this->getParam('mailtags', array()); } /** * In Gmail, conversations will be broken if you reply to a thread and the * server sends back a response without referencing your Message-ID, even if * it references a Message-ID earlier in the thread. To avoid this, use the * parent email's message ID explicitly if it's available. This overwrites the * "In-Reply-To" and "References" headers we would otherwise generate. This * needs to be set whenever an action is triggered by an email message. See * T251 for more details. * * @param string The "Message-ID" of the email which precedes this one. * @return this */ public function setParentMessageID($id) { $this->setParam('parent-message-id', $id); return $this; } public function getParentMessageID() { return $this->getParam('parent-message-id'); } public function getSubject() { return $this->getParam('subject'); } public function addTos(array $phids) { $phids = array_unique($phids); $this->setParam('to', $phids); return $this; } public function addRawTos(array $raw_email) { // Strip addresses down to bare emails, since the MailAdapter API currently // requires we pass it just the address (like `alincoln@logcabin.org`), not // a full string like `"Abraham Lincoln" `. foreach ($raw_email as $key => $email) { $object = new PhutilEmailAddress($email); $raw_email[$key] = $object->getAddress(); } $this->setParam('raw-to', $raw_email); return $this; } public function addCCs(array $phids) { $phids = array_unique($phids); $this->setParam('cc', $phids); return $this; } public function setExcludeMailRecipientPHIDs(array $exclude) { $this->setParam('exclude', $exclude); return $this; } private function getExcludeMailRecipientPHIDs() { return $this->getParam('exclude', array()); } public function setForceHeraldMailRecipientPHIDs(array $force) { $this->setParam('herald-force-recipients', $force); return $this; } private function getForceHeraldMailRecipientPHIDs() { return $this->getParam('herald-force-recipients', array()); } public function addPHIDHeaders($name, array $phids) { $phids = array_unique($phids); foreach ($phids as $phid) { $this->addHeader($name, '<'.$phid.'>'); } return $this; } public function addHeader($name, $value) { $this->parameters['headers'][] = array($name, $value); return $this; } public function addAttachment(PhabricatorMetaMTAAttachment $attachment) { $this->parameters['attachments'][] = $attachment->toDictionary(); return $this; } public function getAttachments() { $dicts = $this->getParam('attachments'); $result = array(); foreach ($dicts as $dict) { $result[] = PhabricatorMetaMTAAttachment::newFromDictionary($dict); } return $result; } public function setAttachments(array $attachments) { assert_instances_of($attachments, 'PhabricatorMetaMTAAttachment'); $this->setParam('attachments', mpull($attachments, 'toDictionary')); return $this; } public function setFrom($from) { $this->setParam('from', $from); $this->setActorPHID($from); return $this; } public function getFrom() { return $this->getParam('from'); } public function setRawFrom($raw_email, $raw_name) { $this->setParam('raw-from', array($raw_email, $raw_name)); return $this; } public function setReplyTo($reply_to) { $this->setParam('reply-to', $reply_to); return $this; } public function setSubject($subject) { $this->setParam('subject', $subject); return $this; } public function setSubjectPrefix($prefix) { $this->setParam('subject-prefix', $prefix); return $this; } public function setVarySubjectPrefix($prefix) { $this->setParam('vary-subject-prefix', $prefix); return $this; } public function setBody($body) { $this->setParam('body', $body); return $this; } public function setSensitiveContent($bool) { $this->setParam('sensitive', $bool); return $this; } public function hasSensitiveContent() { return $this->getParam('sensitive', true); } public function setHTMLBody($html) { $this->setParam('html-body', $html); return $this; } public function getBody() { return $this->getParam('body'); } public function getHTMLBody() { return $this->getParam('html-body'); } public function setIsErrorEmail($is_error) { $this->setParam('is-error', $is_error); return $this; } public function getIsErrorEmail() { return $this->getParam('is-error', false); } public function getToPHIDs() { return $this->getParam('to', array()); } public function getRawToAddresses() { return $this->getParam('raw-to', array()); } public function getCcPHIDs() { return $this->getParam('cc', array()); } /** * Force delivery of a message, even if recipients have preferences which * would otherwise drop the message. * * This is primarily intended to let users who don't want any email still * receive things like password resets. * * @param bool True to force delivery despite user preferences. * @return this */ public function setForceDelivery($force) { $this->setParam('force', $force); return $this; } public function getForceDelivery() { return $this->getParam('force', false); } /** * Flag that this is an auto-generated bulk message and should have bulk * headers added to it if appropriate. Broadly, this means some flavor of * "Precedence: bulk" or similar, but is implementation and configuration * dependent. * * @param bool True if the mail is automated bulk mail. * @return this */ public function setIsBulk($is_bulk) { $this->setParam('is-bulk', $is_bulk); return $this; } /** * Use this method to set an ID used for message threading. MetaMTA will * set appropriate headers (Message-ID, In-Reply-To, References and * Thread-Index) based on the capabilities of the underlying mailer. * * @param string Unique identifier, appropriate for use in a Message-ID, * In-Reply-To or References headers. * @param bool If true, indicates this is the first message in the thread. * @return this */ public function setThreadID($thread_id, $is_first_message = false) { $this->setParam('thread-id', $thread_id); $this->setParam('is-first-message', $is_first_message); return $this; } /** * Save a newly created mail to the database. The mail will eventually be * delivered by the MetaMTA daemon. * * @return this */ public function saveAndSend() { return $this->save(); } public function save() { if ($this->getID()) { return parent::save(); } // NOTE: When mail is sent from CLI scripts that run tasks in-process, we // may re-enter this method from within scheduleTask(). The implementation // is intended to avoid anything awkward if we end up reentering this // method. $this->openTransaction(); // Save to generate a mail ID and PHID. $result = parent::save(); // Write the recipient edges. $editor = new PhabricatorEdgeEditor(); $edge_type = PhabricatorMetaMTAMailHasRecipientEdgeType::EDGECONST; $recipient_phids = array_merge( $this->getToPHIDs(), $this->getCcPHIDs()); $expanded_phids = $this->expandRecipients($recipient_phids); $all_phids = array_unique(array_merge( $recipient_phids, $expanded_phids)); foreach ($all_phids as $curr_phid) { $editor->addEdge($this->getPHID(), $edge_type, $curr_phid); } $editor->save(); // Queue a task to send this mail. $mailer_task = PhabricatorWorker::scheduleTask( 'PhabricatorMetaMTAWorker', $this->getID(), array( 'priority' => PhabricatorWorker::PRIORITY_ALERTS, )); $this->saveTransaction(); return $result; } public function buildDefaultMailer() { return PhabricatorEnv::newObjectFromConfig('metamta.mail-adapter'); } /** * Attempt to deliver an email immediately, in this process. * * @param bool Try to deliver this email even if it has already been * delivered or is in backoff after a failed delivery attempt. * @param PhabricatorMailImplementationAdapter Use a specific mail adapter, * instead of the default. * * @return void */ public function sendNow( $force_send = false, PhabricatorMailImplementationAdapter $mailer = null) { if ($mailer === null) { $mailer = $this->buildDefaultMailer(); } if (!$force_send) { if ($this->getStatus() != PhabricatorMailOutboundStatus::STATUS_QUEUE) { throw new Exception(pht('Trying to send an already-sent mail!')); } } try { $headers = $this->generateHeaders(); $params = $this->parameters; $actors = $this->loadAllActors(); $deliverable_actors = $this->filterDeliverableActors($actors); $default_from = PhabricatorEnv::getEnvConfig('metamta.default-address'); if (empty($params['from'])) { $mailer->setFrom($default_from); } $is_first = idx($params, 'is-first-message'); unset($params['is-first-message']); $is_threaded = (bool)idx($params, 'thread-id'); $reply_to_name = idx($params, 'reply-to-name', ''); unset($params['reply-to-name']); $add_cc = array(); $add_to = array(); // If multiplexing is enabled, some recipients will be in "Cc" // rather than "To". We'll move them to "To" later (or supply a // dummy "To") but need to look for the recipient in either the // "To" or "Cc" fields here. $target_phid = head(idx($params, 'to', array())); if (!$target_phid) { $target_phid = head(idx($params, 'cc', array())); } $preferences = $this->loadPreferences($target_phid); foreach ($params as $key => $value) { switch ($key) { case 'raw-from': list($from_email, $from_name) = $value; $mailer->setFrom($from_email, $from_name); break; case 'from': $from = $value; $actor_email = null; $actor_name = null; $actor = idx($actors, $from); if ($actor) { $actor_email = $actor->getEmailAddress(); $actor_name = $actor->getName(); } $can_send_as_user = $actor_email && PhabricatorEnv::getEnvConfig('metamta.can-send-as-user'); if ($can_send_as_user) { $mailer->setFrom($actor_email, $actor_name); } else { $from_email = coalesce($actor_email, $default_from); $from_name = coalesce($actor_name, pht('Phabricator')); if (empty($params['reply-to'])) { $params['reply-to'] = $from_email; $params['reply-to-name'] = $from_name; } $mailer->setFrom($default_from, $from_name); } break; case 'reply-to': $mailer->addReplyTo($value, $reply_to_name); break; case 'to': $to_phids = $this->expandRecipients($value); $to_actors = array_select_keys($deliverable_actors, $to_phids); $add_to = array_merge( $add_to, mpull($to_actors, 'getEmailAddress')); break; case 'raw-to': $add_to = array_merge($add_to, $value); break; case 'cc': $cc_phids = $this->expandRecipients($value); $cc_actors = array_select_keys($deliverable_actors, $cc_phids); $add_cc = array_merge( $add_cc, mpull($cc_actors, 'getEmailAddress')); break; case 'attachments': $value = $this->getAttachments(); foreach ($value as $attachment) { $mailer->addAttachment( $attachment->getData(), $attachment->getFilename(), $attachment->getMimeType()); } break; case 'subject': $subject = array(); if ($is_threaded) { if ($this->shouldAddRePrefix($preferences)) { $subject[] = 'Re:'; } } $subject[] = trim(idx($params, 'subject-prefix')); $vary_prefix = idx($params, 'vary-subject-prefix'); if ($vary_prefix != '') { if ($this->shouldVarySubject($preferences)) { $subject[] = $vary_prefix; } } $subject[] = $value; $mailer->setSubject(implode(' ', array_filter($subject))); break; case 'thread-id': // NOTE: Gmail freaks out about In-Reply-To and References which // aren't in the form ""; this is also required // by RFC 2822, although some clients are more liberal in what they // accept. $domain = PhabricatorEnv::getEnvConfig('metamta.domain'); $value = '<'.$value.'@'.$domain.'>'; if ($is_first && $mailer->supportsMessageIDHeader()) { $headers[] = array('Message-ID', $value); } else { $in_reply_to = $value; $references = array($value); $parent_id = $this->getParentMessageID(); if ($parent_id) { $in_reply_to = $parent_id; // By RFC 2822, the most immediate parent should appear last // in the "References" header, so this order is intentional. $references[] = $parent_id; } $references = implode(' ', $references); $headers[] = array('In-Reply-To', $in_reply_to); $headers[] = array('References', $references); } $thread_index = $this->generateThreadIndex($value, $is_first); $headers[] = array('Thread-Index', $thread_index); break; default: // Other parameters are handled elsewhere or are not relevant to // constructing the message. break; } } $body = idx($params, 'body', ''); $max = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); if (strlen($body) > $max) { $body = id(new PhutilUTF8StringTruncator()) ->setMaximumBytes($max) ->truncateString($body); $body .= "\n"; $body .= pht('(This email was truncated at %d bytes.)', $max); } $mailer->setBody($body); $html_emails = $this->shouldSendHTML($preferences); if ($html_emails && isset($params['html-body'])) { $mailer->setHTMLBody($params['html-body']); } // Pass the headers to the mailer, then save the state so we can show // them in the web UI. foreach ($headers as $header) { list($header_key, $header_value) = $header; $mailer->addHeader($header_key, $header_value); } $this->setParam('headers.sent', $headers); // Save the final deliverability outcomes and reasoning so we can // explain why things happened the way they did. $actor_list = array(); foreach ($actors as $actor) { $actor_list[$actor->getPHID()] = array( 'deliverable' => $actor->isDeliverable(), 'reasons' => $actor->getDeliverabilityReasons(), ); } $this->setParam('actors.sent', $actor_list); $this->setParam('routing.sent', $this->getParam('routing')); $this->setParam('routingmap.sent', $this->getRoutingRuleMap()); if (!$add_to && !$add_cc) { $this->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID); $this->setMessage( pht( 'Message has no valid recipients: all To/Cc are disabled, '. 'invalid, or configured not to receive this mail.')); return $this->save(); } if ($this->getIsErrorEmail()) { $all_recipients = array_merge($add_to, $add_cc); if ($this->shouldRateLimitMail($all_recipients)) { $this->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID); $this->setMessage( pht( 'This is an error email, but one or more recipients have '. 'exceeded the error email rate limit. Declining to deliver '. 'message.')); return $this->save(); } } if (PhabricatorEnv::getEnvConfig('phabricator.silent')) { $this->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID); $this->setMessage( pht( 'Phabricator is running in silent mode. See `%s` '. 'in the configuration to change this setting.', 'phabricator.silent')); return $this->save(); } // Some mailers require a valid "To:" in order to deliver mail. If we // don't have any "To:", try to fill it in with a placeholder "To:". // If that also fails, move the "Cc:" line to "To:". if (!$add_to) { $placeholder_key = 'metamta.placeholder-to-recipient'; $placeholder = PhabricatorEnv::getEnvConfig($placeholder_key); if ($placeholder !== null) { $add_to = array($placeholder); } else { $add_to = $add_cc; $add_cc = array(); } } $add_to = array_unique($add_to); $add_cc = array_diff(array_unique($add_cc), $add_to); $mailer->addTos($add_to); if ($add_cc) { $mailer->addCCs($add_cc); } } catch (Exception $ex) { $this ->setStatus(PhabricatorMailOutboundStatus::STATUS_FAIL) ->setMessage($ex->getMessage()) ->save(); throw $ex; } try { $ok = $mailer->send(); if (!$ok) { // TODO: At some point, we should clean this up and make all mailers // throw. throw new Exception( pht('Mail adapter encountered an unexpected, unspecified failure.')); } $this->setStatus(PhabricatorMailOutboundStatus::STATUS_SENT); $this->save(); return $this; } catch (PhabricatorMetaMTAPermanentFailureException $ex) { $this ->setStatus(PhabricatorMailOutboundStatus::STATUS_FAIL) ->setMessage($ex->getMessage()) ->save(); throw $ex; } catch (Exception $ex) { $this ->setMessage($ex->getMessage()."\n".$ex->getTraceAsString()) ->save(); throw $ex; } } private function generateThreadIndex($seed, $is_first_mail) { // When threading, Outlook ignores the 'References' and 'In-Reply-To' // headers that most clients use. Instead, it uses a custom 'Thread-Index' // header. The format of this header is something like this (from // camel-exchange-folder.c in Evolution Exchange): /* A new post to a folder gets a 27-byte-long thread index. (The value * is apparently unique but meaningless.) Each reply to a post gets a * 32-byte-long thread index whose first 27 bytes are the same as the * parent's thread index. Each reply to any of those gets a * 37-byte-long thread index, etc. The Thread-Index header contains a * base64 representation of this value. */ // The specific implementation uses a 27-byte header for the first email // a recipient receives, and a random 5-byte suffix (32 bytes total) // thereafter. This means that all the replies are (incorrectly) siblings, // but it would be very difficult to keep track of the entire tree and this // gets us reasonable client behavior. $base = substr(md5($seed), 0, 27); if (!$is_first_mail) { // Not totally sure, but it seems like outlook orders replies by // thread-index rather than timestamp, so to get these to show up in the // right order we use the time as the last 4 bytes. $base .= ' '.pack('N', time()); } return base64_encode($base); } public static function shouldMultiplexAllMail() { return PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient'); } /* -( Managing Recipients )------------------------------------------------ */ /** * Get all of the recipients for this mail, after preference filters are * applied. This list has all objects to whom delivery will be attempted. * * Note that this expands recipients into their members, because delivery * is never directly attempted to aggregate actors like projects. * * @return list A list of all recipients to whom delivery will be * attempted. * @task recipients */ public function buildRecipientList() { $actors = $this->loadAllActors(); $actors = $this->filterDeliverableActors($actors); return mpull($actors, 'getPHID'); } public function loadAllActors() { $actor_phids = $this->getExpandedRecipientPHIDs(); return $this->loadActors($actor_phids); } public function getExpandedRecipientPHIDs() { $actor_phids = $this->getAllActorPHIDs(); return $this->expandRecipients($actor_phids); } private function getAllActorPHIDs() { return array_merge( array($this->getParam('from')), $this->getToPHIDs(), $this->getCcPHIDs()); } /** * Expand a list of recipient PHIDs (possibly including aggregate recipients * like projects) into a deaggregated list of individual recipient PHIDs. * For example, this will expand project PHIDs into a list of the project's * members. * * @param list List of recipient PHIDs, possibly including aggregate * recipients. * @return list Deaggregated list of mailable recipients. */ private function expandRecipients(array $phids) { if ($this->recipientExpansionMap === null) { $all_phids = $this->getAllActorPHIDs(); $this->recipientExpansionMap = id(new PhabricatorMetaMTAMemberQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($all_phids) ->execute(); } $results = array(); foreach ($phids as $phid) { foreach ($this->recipientExpansionMap[$phid] as $recipient_phid) { $results[$recipient_phid] = $recipient_phid; } } return array_keys($results); } private function filterDeliverableActors(array $actors) { assert_instances_of($actors, 'PhabricatorMetaMTAActor'); $deliverable_actors = array(); foreach ($actors as $phid => $actor) { if ($actor->isDeliverable()) { $deliverable_actors[$phid] = $actor; } } return $deliverable_actors; } private function loadActors(array $actor_phids) { $actor_phids = array_filter($actor_phids); $viewer = PhabricatorUser::getOmnipotentUser(); $actors = id(new PhabricatorMetaMTAActorQuery()) ->setViewer($viewer) ->withPHIDs($actor_phids) ->execute(); if (!$actors) { return array(); } if ($this->getForceDelivery()) { // If we're forcing delivery, skip all the opt-out checks. We don't // bother annotating reasoning on the mail in this case because it should // always be obvious why the mail hit this rule (e.g., it is a password // reset mail). foreach ($actors as $actor) { $actor->setDeliverable(PhabricatorMetaMTAActor::REASON_FORCE); } return $actors; } // Exclude explicit recipients. foreach ($this->getExcludeMailRecipientPHIDs() as $phid) { $actor = idx($actors, $phid); if (!$actor) { continue; } $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_RESPONSE); } // Before running more rules, save a list of the actors who were // deliverable before we started running preference-based rules. This stops // us from trying to send mail to disabled users just because a Herald rule // added them, for example. $deliverable = array(); foreach ($actors as $phid => $actor) { if ($actor->isDeliverable()) { $deliverable[] = $phid; } } // For the rest of the rules, order matters. We're going to run all the // possible rules in order from weakest to strongest, and let the strongest // matching rule win. The weaker rules leave annotations behind which help // users understand why the mail was routed the way it was. // Exclude the actor if their preferences are set. $from_phid = $this->getParam('from'); $from_actor = idx($actors, $from_phid); if ($from_actor) { $from_user = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs(array($from_phid)) ->needUserSettings(true) ->execute(); $from_user = head($from_user); if ($from_user) { $pref_key = PhabricatorEmailSelfActionsSetting::SETTINGKEY; $exclude_self = $from_user->getUserSetting($pref_key); if ($exclude_self) { $from_actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_SELF); } } } $all_prefs = id(new PhabricatorUserPreferencesQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUserPHIDs($actor_phids) + ->needSyntheticPreferences(true) ->execute(); $all_prefs = mpull($all_prefs, null, 'getUserPHID'); $value_email = PhabricatorEmailTagsSetting::VALUE_EMAIL; // Exclude all recipients who have set preferences to not receive this type // of email (for example, a user who says they don't want emails about task // CC changes). $tags = $this->getParam('mailtags'); if ($tags) { foreach ($all_prefs as $phid => $prefs) { $user_mailtags = $prefs->getSettingValue( PhabricatorEmailTagsSetting::SETTINGKEY); // The user must have elected to receive mail for at least one // of the mailtags. $send = false; foreach ($tags as $tag) { if (((int)idx($user_mailtags, $tag, $value_email)) == $value_email) { $send = true; break; } } if (!$send) { $actors[$phid]->setUndeliverable( PhabricatorMetaMTAActor::REASON_MAILTAGS); } } } foreach ($deliverable as $phid) { switch ($this->getRoutingRule($phid)) { case PhabricatorMailRoutingRule::ROUTE_AS_NOTIFICATION: $actors[$phid]->setUndeliverable( PhabricatorMetaMTAActor::REASON_ROUTE_AS_NOTIFICATION); break; case PhabricatorMailRoutingRule::ROUTE_AS_MAIL: $actors[$phid]->setDeliverable( PhabricatorMetaMTAActor::REASON_ROUTE_AS_MAIL); break; default: // No change. break; } } // If recipients were initially deliverable and were added by "Send me an // email" Herald rules, annotate them as such and make them deliverable // again, overriding any changes made by the "self mail" and "mail tags" // settings. $force_recipients = $this->getForceHeraldMailRecipientPHIDs(); $force_recipients = array_fuse($force_recipients); if ($force_recipients) { foreach ($deliverable as $phid) { if (isset($force_recipients[$phid])) { $actors[$phid]->setDeliverable( PhabricatorMetaMTAActor::REASON_FORCE_HERALD); } } } // Exclude recipients who don't want any mail. This rule is very strong // and runs last. foreach ($all_prefs as $phid => $prefs) { $exclude = $prefs->getSettingValue( PhabricatorEmailNotificationsSetting::SETTINGKEY); if ($exclude) { $actors[$phid]->setUndeliverable( PhabricatorMetaMTAActor::REASON_MAIL_DISABLED); } } return $actors; } private function shouldRateLimitMail(array $all_recipients) { try { PhabricatorSystemActionEngine::willTakeAction( $all_recipients, new PhabricatorMetaMTAErrorMailAction(), 1); return false; } catch (PhabricatorSystemActionRateLimitException $ex) { return true; } } public function delete() { $this->openTransaction(); queryfx( $this->establishConnection('w'), 'DELETE FROM %T WHERE src = %s AND type = %d', PhabricatorEdgeConfig::TABLE_NAME_EDGE, $this->getPHID(), PhabricatorMetaMTAMailHasRecipientEdgeType::EDGECONST); $ret = parent::delete(); $this->saveTransaction(); return $ret; } public function generateHeaders() { $headers = array(); $headers[] = array('X-Phabricator-Sent-This-Message', 'Yes'); $headers[] = array('X-Mail-Transport-Agent', 'MetaMTA'); // Some clients respect this to suppress OOF and other auto-responses. $headers[] = array('X-Auto-Response-Suppress', 'All'); // If the message has mailtags, filter out any recipients who don't want // to receive this type of mail. $mailtags = $this->getParam('mailtags'); if ($mailtags) { $tag_header = array(); foreach ($mailtags as $mailtag) { $tag_header[] = '<'.$mailtag.'>'; } $tag_header = implode(', ', $tag_header); $headers[] = array('X-Phabricator-Mail-Tags', $tag_header); } $value = $this->getParam('headers', array()); foreach ($value as $pair) { list($header_key, $header_value) = $pair; // NOTE: If we have \n in a header, SES rejects the email. $header_value = str_replace("\n", ' ', $header_value); $headers[] = array($header_key, $header_value); } $is_bulk = $this->getParam('is-bulk'); if ($is_bulk) { $headers[] = array('Precedence', 'bulk'); } return $headers; } public function getDeliveredHeaders() { return $this->getParam('headers.sent'); } public function getDeliveredActors() { return $this->getParam('actors.sent'); } public function getDeliveredRoutingRules() { return $this->getParam('routing.sent'); } public function getDeliveredRoutingMap() { return $this->getParam('routingmap.sent'); } /* -( Routing )------------------------------------------------------------ */ public function addRoutingRule($routing_rule, $phids, $reason_phid) { $routing = $this->getParam('routing', array()); $routing[] = array( 'routingRule' => $routing_rule, 'phids' => $phids, 'reasonPHID' => $reason_phid, ); $this->setParam('routing', $routing); // Throw the routing map away so we rebuild it. $this->routingMap = null; return $this; } private function getRoutingRule($phid) { $map = $this->getRoutingRuleMap(); $info = idx($map, $phid, idx($map, 'default')); if ($info) { return idx($info, 'rule'); } return null; } private function getRoutingRuleMap() { if ($this->routingMap === null) { $map = array(); $routing = $this->getParam('routing', array()); foreach ($routing as $route) { $phids = $route['phids']; if ($phids === null) { $phids = array('default'); } foreach ($phids as $phid) { $new_rule = $route['routingRule']; $current_rule = idx($map, $phid); if ($current_rule === null) { $is_stronger = true; } else { $is_stronger = PhabricatorMailRoutingRule::isStrongerThan( $new_rule, $current_rule); } if ($is_stronger) { $map[$phid] = array( 'rule' => $new_rule, 'reason' => $route['reasonPHID'], ); } } } $this->routingMap = $map; } return $this->routingMap; } /* -( Preferences )-------------------------------------------------------- */ private function loadPreferences($target_phid) { - if (!self::shouldMultiplexAllMail()) { - $target_phid = null; - } + $viewer = PhabricatorUser::getOmnipotentUser(); - if ($target_phid) { + if (self::shouldMultiplexAllMail()) { $preferences = id(new PhabricatorUserPreferencesQuery()) - ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->setViewer($viewer) ->withUserPHIDs(array($target_phid)) + ->needSyntheticPreferences(true) ->executeOne(); - } else { - $preferences = null; - } - - // TODO: Here, we would load global preferences once they exist. - - if (!$preferences) { - // If we haven't found suitable preferences yet, return an empty object - // which implicitly has all the default values. - $preferences = id(new PhabricatorUserPreferences()) - ->attachUser(new PhabricatorUser()); + if ($preferences) { + return $preferences; + } } - return $preferences; + return PhabricatorUserPreferences::loadGlobalPreferences($viewer); } private function shouldAddRePrefix(PhabricatorUserPreferences $preferences) { $value = $preferences->getSettingValue( PhabricatorEmailRePrefixSetting::SETTINGKEY); return ($value == PhabricatorEmailRePrefixSetting::VALUE_RE_PREFIX); } private function shouldVarySubject(PhabricatorUserPreferences $preferences) { $value = $preferences->getSettingValue( PhabricatorEmailVarySubjectsSetting::SETTINGKEY); return ($value == PhabricatorEmailVarySubjectsSetting::VALUE_VARY_SUBJECTS); } private function shouldSendHTML(PhabricatorUserPreferences $preferences) { $value = $preferences->getSettingValue( PhabricatorEmailFormatSetting::SETTINGKEY); return ($value == PhabricatorEmailFormatSetting::VALUE_HTML_EMAIL); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return PhabricatorPolicies::POLICY_NOONE; } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { $actor_phids = $this->getExpandedRecipientPHIDs(); return in_array($viewer->getPHID(), $actor_phids); } public function describeAutomaticCapability($capability) { return pht( 'The mail sender and message recipients can always see the mail.'); } } diff --git a/src/applications/people/cache/PhabricatorUserPreferencesCacheType.php b/src/applications/people/cache/PhabricatorUserPreferencesCacheType.php index 015a24cb0f..7fee680def 100644 --- a/src/applications/people/cache/PhabricatorUserPreferencesCacheType.php +++ b/src/applications/people/cache/PhabricatorUserPreferencesCacheType.php @@ -1,85 +1,72 @@ getViewer(); $users = mpull($users, null, 'getPHID'); $user_phids = array_keys($users); $preferences = id(new PhabricatorUserPreferencesQuery()) ->setViewer($viewer) - ->withUserPHIDs($user_phids) + ->withUsers($users) + ->needSyntheticPreferences(true) ->execute(); $preferences = mpull($preferences, null, 'getUserPHID'); - // If some users don't have settings of their own yet, we need to load - // the global default settings to generate caches for them. - if (count($preferences) < count($user_phids)) { - $global = id(new PhabricatorUserPreferencesQuery()) - ->setViewer($viewer) - ->withBuiltinKeys( - array( - PhabricatorUserPreferences::BUILTIN_GLOBAL_DEFAULT, - )) - ->executeOne(); - } else { - $global = null; - } - $all_settings = PhabricatorSetting::getAllSettings(); $settings = array(); foreach ($users as $user_phid => $user) { - $preference = idx($preferences, $user_phid, $global); + $preference = idx($preferences, $user_phid); if (!$preference) { continue; } foreach ($all_settings as $key => $setting) { $value = $preference->getSettingValue($key); // As an optimization, we omit the value from the cache if it is // exactly the same as the hardcoded default. $default_value = id(clone $setting) ->setViewer($user) ->getSettingDefaultValue(); if ($value === $default_value) { continue; } $settings[$user_phid][$key] = $value; } } $results = array(); foreach ($user_phids as $user_phid) { $value = idx($settings, $user_phid, array()); $results[$user_phid] = phutil_json_encode($value); } return $results; } } diff --git a/src/applications/settings/controller/PhabricatorSettingsMainController.php b/src/applications/settings/controller/PhabricatorSettingsMainController.php index 2a368d8650..fada4a0937 100644 --- a/src/applications/settings/controller/PhabricatorSettingsMainController.php +++ b/src/applications/settings/controller/PhabricatorSettingsMainController.php @@ -1,221 +1,225 @@ user; } private function isSelf() { $user = $this->getUser(); if (!$user) { return false; } $user_phid = $user->getPHID(); $viewer_phid = $this->getViewer()->getPHID(); return ($viewer_phid == $user_phid); } private function isTemplate() { return ($this->builtinKey !== null); } public function handleRequest(AphrontRequest $request) { $viewer = $this->getViewer(); // Redirect "/panel/XYZ/" to the viewer's personal settings panel. This // was the primary URI before global settings were introduced and allows // generation of viewer-agnostic URIs for email. $panel = $request->getURIData('panel'); if ($panel) { $panel = phutil_escape_uri($panel); $username = $viewer->getUsername(); $panel_uri = "/user/{$username}/page/{$panel}/"; $panel_uri = $this->getApplicationURI($panel_uri); return id(new AphrontRedirectResponse())->setURI($panel_uri); } $username = $request->getURIData('username'); $builtin = $request->getURIData('builtin'); $key = $request->getURIData('pageKey'); if ($builtin) { $this->builtinKey = $builtin; $preferences = id(new PhabricatorUserPreferencesQuery()) ->setViewer($viewer) ->withBuiltinKeys(array($builtin)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$preferences) { $preferences = id(new PhabricatorUserPreferences()) ->attachUser(null) ->setBuiltinKey($builtin); } } else { $user = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withUsernames(array($username)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$user) { return new Aphront404Response(); } $preferences = PhabricatorUserPreferences::loadUserPreferences($user); $this->user = $user; } if (!$preferences) { return new Aphront404Response(); } PhabricatorPolicyFilter::requireCapability( $viewer, $preferences, PhabricatorPolicyCapability::CAN_EDIT); $this->preferences = $preferences; $panels = $this->buildPanels($preferences); $nav = $this->renderSideNav($panels); $key = $nav->selectFilter($key, head($panels)->getPanelKey()); $panel = $panels[$key] ->setController($this) ->setNavigation($nav); $response = $panel->processRequest($request); if (($response instanceof AphrontResponse) || ($response instanceof AphrontResponseProducerInterface)) { return $response; } $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($panel->getPanelName()); $title = $panel->getPanelName(); $view = id(new PHUITwoColumnView()) ->setNavigation($nav) ->setMainColumn($response); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($view); } private function buildPanels(PhabricatorUserPreferences $preferences) { $viewer = $this->getViewer(); $panels = PhabricatorSettingsPanel::getAllDisplayPanels(); $result = array(); foreach ($panels as $key => $panel) { $panel ->setPreferences($preferences) ->setViewer($viewer); if ($this->user) { $panel->setUser($this->user); } if (!$panel->isEnabled()) { continue; } if ($this->isTemplate()) { if (!$panel->isTemplatePanel()) { continue; } } else { if (!$this->isSelf() && !$panel->isManagementPanel()) { continue; } + + if ($this->isSelf() && !$panel->isUserPanel()) { + continue; + } } if (!empty($result[$key])) { throw new Exception(pht( "Two settings panels share the same panel key ('%s'): %s, %s.", $key, get_class($panel), get_class($result[$key]))); } $result[$key] = $panel; } if (!$result) { throw new Exception(pht('No settings panels are available.')); } return $result; } private function renderSideNav(array $panels) { $nav = new AphrontSideNavFilterView(); if ($this->isTemplate()) { $base_uri = 'builtin/'.$this->builtinKey.'/page/'; } else { $user = $this->getUser(); $base_uri = 'user/'.$user->getUsername().'/page/'; } $nav->setBaseURI(new PhutilURI($this->getApplicationURI($base_uri))); $group_key = null; foreach ($panels as $panel) { if ($panel->getPanelGroupKey() != $group_key) { $group_key = $panel->getPanelGroupKey(); $group = $panel->getPanelGroup(); $nav->addLabel($group->getPanelGroupName()); } $nav->addFilter($panel->getPanelKey(), $panel->getPanelName()); } return $nav; } public function buildApplicationMenu() { if ($this->preferences) { $panels = $this->buildPanels($this->preferences); return $this->renderSideNav($panels)->getMenu(); } return parent::buildApplicationMenu(); } protected function buildApplicationCrumbs() { $crumbs = parent::buildApplicationCrumbs(); $user = $this->getUser(); if (!$this->isSelf() && $user) { $username = $user->getUsername(); $crumbs->addTextCrumb($username, "/p/{$username}/"); } return $crumbs; } } diff --git a/src/applications/settings/panel/PhabricatorEmailFormatSettingsPanel.php b/src/applications/settings/panel/PhabricatorEmailFormatSettingsPanel.php index 5cab5dea41..107816f2eb 100644 --- a/src/applications/settings/panel/PhabricatorEmailFormatSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorEmailFormatSettingsPanel.php @@ -1,28 +1,36 @@ isUserPanel()) { + return false; + } + if ($this->getUser()->getIsMailingList()) { return true; } return false; } public function isTemplatePanel() { return true; } } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanel.php b/src/applications/settings/panel/PhabricatorSettingsPanel.php index b66b03f9c8..7d86eaf243 100644 --- a/src/applications/settings/panel/PhabricatorSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanel.php @@ -1,265 +1,277 @@ user = $user; return $this; } public function getUser() { return $this->user; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setOverrideURI($override_uri) { $this->overrideURI = $override_uri; return $this; } final public function setController(PhabricatorController $controller) { $this->controller = $controller; return $this; } final public function getController() { return $this->controller; } final public function setNavigation(AphrontSideNavFilterView $navigation) { $this->navigation = $navigation; return $this; } final public function getNavigation() { return $this->navigation; } public function setPreferences(PhabricatorUserPreferences $preferences) { $this->preferences = $preferences; return $this; } public function getPreferences() { return $this->preferences; } final public static function getAllPanels() { $panels = id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getPanelKey') ->execute(); return msortv($panels, 'getPanelOrderVector'); } final public static function getAllDisplayPanels() { $panels = array(); $groups = PhabricatorSettingsPanelGroup::getAllPanelGroupsWithPanels(); foreach ($groups as $group) { foreach ($group->getPanels() as $key => $panel) { $panels[$key] = $panel; } } return $panels; } final public function getPanelGroup() { $group_key = $this->getPanelGroupKey(); $groups = PhabricatorSettingsPanelGroup::getAllPanelGroupsWithPanels(); $group = idx($groups, $group_key); if (!$group) { throw new Exception( pht( 'No settings panel group with key "%s" exists!', $group_key)); } return $group; } /* -( Panel Configuration )------------------------------------------------ */ /** * Return a unique string used in the URI to identify this panel, like * "example". * * @return string Unique panel identifier (used in URIs). * @task config */ public function getPanelKey() { return $this->getPhobjectClassConstant('PANELKEY'); } /** * Return a human-readable description of the panel's contents, like * "Example Settings". * * @return string Human-readable panel name. * @task config */ abstract public function getPanelName(); /** * Return a panel group key constant for this panel. * * @return const Panel group key. * @task config */ abstract public function getPanelGroupKey(); /** * Return false to prevent this panel from being displayed or used. You can * do, e.g., configuration checks here, to determine if the feature your * panel controls is unavailble in this install. By default, all panels are * enabled. * * @return bool True if the panel should be shown. * @task config */ public function isEnabled() { return true; } + /** + * Return true if this panel is available to users while editing their own + * settings. + * + * @return bool True to enable management on behalf of a user. + * @task config + */ + public function isUserPanel() { + return true; + } + + /** * Return true if this panel is available to administrators while managing * bot and mailing list accounts. * * @return bool True to enable management on behalf of accounts. * @task config */ public function isManagementPanel() { return false; } /** * Return true if this panel is available while editing settings templates. * * @return bool True to allow editing in templates. * @task config */ public function isTemplatePanel() { return false; } /* -( Panel Implementation )----------------------------------------------- */ /** * Process a user request for this settings panel. Implement this method like * a lightweight controller. If you return an @{class:AphrontResponse}, the * response will be used in whole. If you return anything else, it will be * treated as a view and composed into a normal settings page. * * Generally, render your settings panel by returning a form, then return * a redirect when the user saves settings. * * @param AphrontRequest Incoming request. * @return wild Response to request, either as an * @{class:AphrontResponse} or something which can * be composed into a @{class:AphrontView}. * @task panel */ abstract public function processRequest(AphrontRequest $request); /** * Get the URI for this panel. * * @param string? Optional path to append. * @return string Relative URI for the panel. * @task panel */ final public function getPanelURI($path = '') { $path = ltrim($path, '/'); if ($this->overrideURI) { return rtrim($this->overrideURI, '/').'/'.$path; } $key = $this->getPanelKey(); $key = phutil_escape_uri($key); $user = $this->getUser(); if ($user) { $username = $user->getUsername(); return "/settings/user/{$username}/page/{$key}/{$path}"; } else { $builtin = $this->getPreferences()->getBuiltinKey(); return "/settings/builtin/{$builtin}/page/{$key}/{$path}"; } } /* -( Internals )---------------------------------------------------------- */ /** * Generates a key to sort the list of panels. * * @return string Sortable key. * @task internal */ final public function getPanelOrderVector() { return id(new PhutilSortVector()) ->addString($this->getPanelName()); } protected function newDialog() { return $this->getController()->newDialog(); } protected function writeSetting( PhabricatorUserPreferences $preferences, $key, $value) { $viewer = $this->getViewer(); $request = $this->getController()->getRequest(); $editor = id(new PhabricatorUserPreferencesEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); $xactions = array(); $xactions[] = $preferences->newTransaction($key, $value); $editor->applyTransactions($preferences, $xactions); } } diff --git a/src/applications/settings/query/PhabricatorUserPreferencesQuery.php b/src/applications/settings/query/PhabricatorUserPreferencesQuery.php index 280ca3eea6..de4887cbb8 100644 --- a/src/applications/settings/query/PhabricatorUserPreferencesQuery.php +++ b/src/applications/settings/query/PhabricatorUserPreferencesQuery.php @@ -1,169 +1,196 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withHasUserPHID($is_user) { $this->hasUserPHID = $is_user; return $this; } public function withUserPHIDs(array $phids) { $this->userPHIDs = $phids; return $this; } public function withUsers(array $users) { assert_instances_of($users, 'PhabricatorUser'); $this->users = mpull($users, null, 'getPHID'); $this->withUserPHIDs(array_keys($this->users)); return $this; } public function withBuiltinKeys(array $keys) { $this->builtinKeys = $keys; return $this; } + /** + * Always return preferences for every queried user. + * + * If no settings exist for a user, a new empty settings object with + * appropriate defaults is returned. + * + * @param bool True to generat synthetic preferences for missing users. + */ + public function needSyntheticPreferences($synthetic) { + $this->synthetic = $synthetic; + return $this; + } + public function newResultObject() { return new PhabricatorUserPreferences(); } protected function loadPage() { - return $this->loadStandardPage($this->newResultObject()); + $preferences = $this->loadStandardPage($this->newResultObject()); + + if ($this->synthetic) { + $user_map = mpull($preferences, null, 'getUserPHID'); + foreach ($this->userPHIDs as $user_phid) { + if (isset($user_map[$user_phid])) { + continue; + } + $preferences[] = $this->newResultObject() + ->setUserPHID($user_phid); + } + } + + return $preferences; } protected function willFilterPage(array $prefs) { $user_phids = mpull($prefs, 'getUserPHID'); $user_phids = array_filter($user_phids); // If some of the preferences are attached to users, try to use any objects // we were handed first. If we're missing some, load them. if ($user_phids) { $users = $this->users; $user_phids = array_fuse($user_phids); $load_phids = array_diff_key($user_phids, $users); $load_phids = array_keys($load_phids); if ($load_phids) { $load_users = id(new PhabricatorPeopleQuery()) ->setViewer($this->getViewer()) ->withPHIDs($load_phids) ->execute(); $load_users = mpull($load_users, null, 'getPHID'); $users += $load_users; } } else { $users = array(); } $need_global = array(); foreach ($prefs as $key => $pref) { $user_phid = $pref->getUserPHID(); if (!$user_phid) { $pref->attachUser(null); continue; } $need_global[] = $pref; $user = idx($users, $user_phid); if (!$user) { $this->didRejectResult($pref); unset($prefs[$key]); continue; } $pref->attachUser($user); } // If we loaded any user preferences, load the global defaults and attach // them if they exist. if ($need_global) { $global = id(new self()) ->setViewer($this->getViewer()) ->withBuiltinKeys( array( PhabricatorUserPreferences::BUILTIN_GLOBAL_DEFAULT, )) ->executeOne(); if ($global) { foreach ($need_global as $pref) { $pref->attachDefaultSettings($global); } } } return $prefs; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->ids !== null) { $where[] = qsprintf( $conn, 'id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'phid IN (%Ls)', $this->phids); } if ($this->userPHIDs !== null) { $where[] = qsprintf( $conn, 'userPHID IN (%Ls)', $this->userPHIDs); } if ($this->builtinKeys !== null) { $where[] = qsprintf( $conn, 'builtinKey IN (%Ls)', $this->builtinKeys); } if ($this->hasUserPHID !== null) { if ($this->hasUserPHID) { $where[] = qsprintf( $conn, 'userPHID IS NOT NULL'); } else { $where[] = qsprintf( $conn, 'userPHID IS NULL'); } } return $where; } public function getQueryApplicationClass() { return 'PhabricatorSettingsApplication'; } } diff --git a/src/applications/settings/setting/PhabricatorEmailFormatSetting.php b/src/applications/settings/setting/PhabricatorEmailFormatSetting.php index 0cf0db5d74..333d85c6f4 100644 --- a/src/applications/settings/setting/PhabricatorEmailFormatSetting.php +++ b/src/applications/settings/setting/PhabricatorEmailFormatSetting.php @@ -1,44 +1,40 @@ pht('Send HTML Email'), self::VALUE_TEXT_EMAIL => pht('Send Plain Text Email'), ); } } diff --git a/src/applications/settings/setting/PhabricatorEmailRePrefixSetting.php b/src/applications/settings/setting/PhabricatorEmailRePrefixSetting.php index 7b04ef80c3..5e70b731cd 100644 --- a/src/applications/settings/setting/PhabricatorEmailRePrefixSetting.php +++ b/src/applications/settings/setting/PhabricatorEmailRePrefixSetting.php @@ -1,52 +1,48 @@ pht('Enable "Re:" Prefix'), self::VALUE_NO_PREFIX => pht('Disable "Re:" Prefix'), ); } } diff --git a/src/applications/settings/setting/PhabricatorEmailVarySubjectsSetting.php b/src/applications/settings/setting/PhabricatorEmailVarySubjectsSetting.php index 0c6b73907b..1c088d9411 100644 --- a/src/applications/settings/setting/PhabricatorEmailVarySubjectsSetting.php +++ b/src/applications/settings/setting/PhabricatorEmailVarySubjectsSetting.php @@ -1,56 +1,52 @@ pht('Enable Vary Subjects'), self::VALUE_STATIC_SUBJECTS => pht('Disable Vary Subjects'), ); } } diff --git a/src/applications/settings/storage/PhabricatorUserPreferences.php b/src/applications/settings/storage/PhabricatorUserPreferences.php index b03365bd32..5ed360ca2c 100644 --- a/src/applications/settings/storage/PhabricatorUserPreferences.php +++ b/src/applications/settings/storage/PhabricatorUserPreferences.php @@ -1,256 +1,260 @@ true, self::CONFIG_SERIALIZATION => array( 'preferences' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'userPHID' => 'phid?', 'builtinKey' => 'text32?', ), self::CONFIG_KEY_SCHEMA => array( 'key_user' => array( 'columns' => array('userPHID'), 'unique' => true, ), 'key_builtin' => array( 'columns' => array('builtinKey'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorUserPreferencesPHIDType::TYPECONST); } public function getPreference($key, $default = null) { return idx($this->preferences, $key, $default); } public function setPreference($key, $value) { $this->preferences[$key] = $value; return $this; } public function unsetPreference($key) { unset($this->preferences[$key]); return $this; } public function getDefaultValue($key) { if ($this->defaultSettings) { return $this->defaultSettings->getSettingValue($key); } $setting = self::getSettingObject($key); if (!$setting) { return null; } $setting = id(clone $setting) ->setViewer($this->getUser()); return $setting->getSettingDefaultValue(); } public function getSettingValue($key) { if (array_key_exists($key, $this->preferences)) { return $this->preferences[$key]; } return $this->getDefaultValue($key); } private static function getSettingObject($key) { $settings = PhabricatorSetting::getAllSettings(); return idx($settings, $key); } public function attachDefaultSettings(PhabricatorUserPreferences $settings) { $this->defaultSettings = $settings; return $this; } public function attachUser(PhabricatorUser $user = null) { $this->user = $user; return $this; } public function getUser() { return $this->assertAttached($this->user); } public function hasManagedUser() { $user_phid = $this->getUserPHID(); if (!$user_phid) { return false; } $user = $this->getUser(); if ($user->getIsSystemAgent() || $user->getIsMailingList()) { return true; } return false; } /** * Load or create a preferences object for the given user. * * @param PhabricatorUser User to load or create preferences for. */ public static function loadUserPreferences(PhabricatorUser $user) { - $preferences = id(new PhabricatorUserPreferencesQuery()) + return id(new PhabricatorUserPreferencesQuery()) ->setViewer($user) ->withUsers(array($user)) + ->needSyntheticPreferences(true) ->executeOne(); - if ($preferences) { - return $preferences; - } - - $preferences = id(new self()) - ->setUserPHID($user->getPHID()) - ->attachUser($user); + } + /** + * Load or create a global preferences object. + * + * If no global preferences exist, an empty preferences object is returned. + * + * @param PhabricatorUser Viewing user. + */ + public static function loadGlobalPreferences(PhabricatorUser $viewer) { $global = id(new PhabricatorUserPreferencesQuery()) - ->setViewer($user) + ->setViewer($viewer) ->withBuiltinKeys( array( self::BUILTIN_GLOBAL_DEFAULT, )) ->executeOne(); - if ($global) { - $preferences->attachDefaultSettings($global); + if (!$global) { + $global = id(new self()) + ->attachUser(new PhabricatorUser()); } - return $preferences; + return $global; } public function newTransaction($key, $value) { $setting_property = PhabricatorUserPreferencesTransaction::PROPERTY_SETTING; $xaction_type = PhabricatorUserPreferencesTransaction::TYPE_SETTING; return id(clone $this->getApplicationTransactionTemplate()) ->setTransactionType($xaction_type) ->setMetadataValue($setting_property, $key) ->setNewValue($value); } public function getEditURI() { if ($this->getUser()) { return '/settings/user/'.$this->getUser()->getUsername().'/'; } else { return '/settings/builtin/'.$this->getBuiltinKey().'/'; } } public function getDisplayName() { if ($this->getBuiltinKey()) { return pht('Global Default Settings'); } return pht('Personal Settings'); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $user_phid = $this->getUserPHID(); if ($user_phid) { return $user_phid; } return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: if ($this->hasManagedUser()) { return PhabricatorPolicies::POLICY_ADMIN; } $user_phid = $this->getUserPHID(); if ($user_phid) { return $user_phid; } return PhabricatorPolicies::POLICY_ADMIN; } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { if ($this->hasManagedUser()) { if ($viewer->getIsAdmin()) { return true; } } return false; } public function describeAutomaticCapability($capability) { return null; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->delete(); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorUserPreferencesEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorUserPreferencesTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } }