Merge remote-tracking branch 'upstream/4.5' into 4.5-master-2015-03-09-21-44-34
[civicrm-core.git] / CRM / Mailing / BAO / Mailing.php
index d826725971e8f7b76376bec2494773ca0ee4cebd..94e7f7f40a09f0009d11d25e383d70608a695f5e 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 /*
  +--------------------------------------------------------------------+
- | CiviCRM version 4.5                                                |
+ | CiviCRM version 4.6                                                |
  +--------------------------------------------------------------------+
  | Copyright CiviCRM LLC (c) 2004-2014                                |
  +--------------------------------------------------------------------+
@@ -23,7 +23,7 @@
  | GNU Affero General Public License or the licensing of CiviCRM,     |
  | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
  +--------------------------------------------------------------------+
-*/
+ */
 
 /**
  *
@@ -89,31 +89,32 @@ class CRM_Mailing_BAO_Mailing extends CRM_Mailing_DAO_Mailing {
   private $_domain = NULL;
 
   /**
-   * class constructor
+   * Class constructor
    */
-  function __construct() {
+  public function __construct() {
     parent::__construct();
   }
 
   /**
-   * @param $job_id
-   * @param null $mailing_id
+   * @param int $job_id
+   * @param int $mailing_id
    * @param null $mode
    *
    * @return int
    */
-  static function &getRecipientsCount($job_id, $mailing_id = NULL, $mode = NULL) {
+  public static function &getRecipientsCount($job_id, $mailing_id = NULL, $mode = NULL) {
     // need this for backward compatibility, so we can get count for old mailings
     // please do not use this function if possible
     $eq = self::getRecipients($job_id, $mailing_id);
     return $eq->N;
   }
 
-  // note that $job_id is used only as a variable in the temp table construction
-  // and does not play a role in the queries generated
   /**
-   * @param $job_id
-   * @param null $mailing_id
+   * note that $job_id is used only as a variable in the temp table construction
+   * and does not play a role in the queries generated
+   * @param int $job_id
+   *   (misnomer) a nonce value used to name temporary tables.
+   * @param int $mailing_id
    * @param null $offset
    * @param null $limit
    * @param bool $storeRecipients
@@ -122,7 +123,7 @@ class CRM_Mailing_BAO_Mailing extends CRM_Mailing_DAO_Mailing {
    *
    * @return CRM_Mailing_Event_BAO_Queue|string
    */
-  static function &getRecipients(
+  public static function &getRecipients(
     $job_id,
     $mailing_id = NULL,
     $offset = NULL,
@@ -133,11 +134,11 @@ class CRM_Mailing_BAO_Mailing extends CRM_Mailing_DAO_Mailing {
     $mailingGroup = new CRM_Mailing_DAO_MailingGroup();
 
     $mailing = CRM_Mailing_BAO_Mailing::getTableName();
-    $job     = CRM_Mailing_BAO_MailingJob::getTableName();
-    $mg      = CRM_Mailing_DAO_MailingGroup::getTableName();
-    $eq      = CRM_Mailing_Event_DAO_Queue::getTableName();
-    $ed      = CRM_Mailing_Event_DAO_Delivered::getTableName();
-    $eb      = CRM_Mailing_Event_DAO_Bounce::getTableName();
+    $job = CRM_Mailing_BAO_MailingJob::getTableName();
+    $mg = CRM_Mailing_DAO_MailingGroup::getTableName();
+    $eq = CRM_Mailing_Event_DAO_Queue::getTableName();
+    $ed = CRM_Mailing_Event_DAO_Delivered::getTableName();
+    $eb = CRM_Mailing_Event_DAO_Bounce::getTableName();
 
     $email = CRM_Core_DAO_Email::getTableName();
     if ($mode == 'sms') {
@@ -148,6 +149,54 @@ class CRM_Mailing_BAO_Mailing extends CRM_Mailing_DAO_Mailing {
     $group = CRM_Contact_DAO_Group::getTableName();
     $g2contact = CRM_Contact_DAO_GroupContact::getTableName();
 
+    $m = new CRM_Mailing_DAO_Mailing();
+    $m->id = $mailing_id;
+    $m->find(TRUE);
+
+    $email_selection_method = $m->email_selection_method;
+    $location_type_id = $m->location_type_id;
+
+    // Note: When determining the ORDER that results are returned, it's
+    // the record that comes last that counts. That's because we are
+    // INSERT'ing INTO a table with a primary id so that last record
+    // over writes any previous record.
+    switch ($email_selection_method) {
+      case 'location-exclude':
+        $location_filter = "($email.location_type_id != $location_type_id)";
+        // If there is more than one email that doesn't match the location,
+        // prefer the one marked is_bulkmail, followed by is_primary.
+        $order_by = "ORDER BY $email.is_bulkmail, $email.is_primary";
+        break;
+
+      case 'location-only':
+        $location_filter = "($email.location_type_id = $location_type_id)";
+        // If there is more than one email of the desired location, prefer
+        // the one marked is_bulkmail, followed by is_primary.
+        $order_by = "ORDER BY $email.is_bulkmail, $email.is_primary";
+        break;
+
+      case 'location-prefer':
+        $location_filter = "($email.is_bulkmail = 1 OR $email.is_primary = 1 OR $email.location_type_id = $location_type_id)";
+
+        // ORDER BY is more complicated because we have to set an arbitrary
+        // order that prefers the location that we want. We do that using
+        // the FIELD function. For more info, see:
+        // https://dev.mysql.com/doc/refman/5.5/en/string-functions.html#function_field
+        // We assign the location type we want the value "1" by putting it
+        // in the first position after we name the field. All other location
+        // types are left out, so they will be assigned the value 0. That
+        // means, they will all be equally tied for first place, with our
+        // location being last.
+        $order_by = "ORDER BY FIELD($email.location_type_id, $location_type_id), $email.is_bulkmail, $email.is_primary";
+        break;
+
+      case 'automatic':
+        // fall through to default
+      default:
+        $location_filter = "($email.is_bulkmail = 1 OR $email.is_primary = 1)";
+        $order_by = "ORDER BY $email.is_bulkmail";
+    }
+
     /* Create a temp table for contact exclusion */
     $mailingGroup->query(
       "CREATE TEMPORARY TABLE X_$job_id
@@ -156,7 +205,7 @@ class CRM_Mailing_BAO_Mailing extends CRM_Mailing_DAO_Mailing {
     );
 
     /* Add all the members of groups excluded from this mailing to the temp
-         * table */
+     * table */
 
     $excludeSubGroup = "INSERT INTO        X_$job_id (contact_id)
                     SELECT  DISTINCT    $g2contact.contact_id
@@ -170,7 +219,7 @@ class CRM_Mailing_BAO_Mailing extends CRM_Mailing_DAO_Mailing {
     $mailingGroup->query($excludeSubGroup);
 
     /* Add all unsubscribe members of base group from this mailing to the temp
-         * table */
+     * table */
 
     $unSubscribeBaseGroup = "INSERT INTO        X_$job_id (contact_id)
                     SELECT  DISTINCT    $g2contact.contact_id
@@ -184,7 +233,7 @@ class CRM_Mailing_BAO_Mailing extends CRM_Mailing_DAO_Mailing {
     $mailingGroup->query($unSubscribeBaseGroup);
 
     /* Add all the (intended) recipients of an excluded prior mailing to
-         * the temp table */
+     * the temp table */
 
     $excludeSubMailing = "INSERT IGNORE INTO X_$job_id (contact_id)
                     SELECT  DISTINCT    $eq.contact_id
@@ -241,7 +290,7 @@ WHERE  c.group_id = {$groupDAO->id}
     );
 
     /* Get the group contacts, but only those which are not in the
-         * exclusion temp table */
+     * exclusion temp table */
 
     $query = "REPLACE INTO       I_$job_id (email_id, contact_id)
 
@@ -264,17 +313,17 @@ WHERE  c.group_id = {$groupDAO->id}
                         AND             $contact.do_not_email = 0
                         AND             $contact.is_opt_out = 0
                         AND             $contact.is_deceased = 0
-                        AND            ($email.is_bulkmail = 1 OR $email.is_primary = 1)
+                        AND             $location_filter
                         AND             $email.email IS NOT NULL
                         AND             $email.email != ''
                         AND             $email.on_hold = 0
                         AND             $mg.mailing_id = {$mailing_id}
                         AND             X_$job_id.contact_id IS null
-                    ORDER BY $email.is_bulkmail";
+                    $order_by";
 
     if ($mode == 'sms') {
       $phoneTypes = CRM_Core_OptionGroup::values('phone_type', TRUE, FALSE, FALSE, NULL, 'name');
-      $query      = "REPLACE INTO       I_$job_id (phone_id, contact_id)
+      $query = "REPLACE INTO       I_$job_id (phone_id, contact_id)
 
                     SELECT DISTINCT     $phone.id as phone_id,
                                         $contact.id as contact_id
@@ -324,11 +373,11 @@ WHERE  c.group_id = {$groupDAO->id}
                         AND             $contact.do_not_email = 0
                         AND             $contact.is_opt_out = 0
                         AND             $contact.is_deceased = 0
-                        AND            ($email.is_bulkmail = 1 OR $email.is_primary = 1)
+                        AND             $location_filter
                         AND             $email.on_hold = 0
                         AND             $mg.mailing_id = {$mailing_id}
                         AND             X_$job_id.contact_id IS null
-                    ORDER BY $email.is_bulkmail";
+                    $order_by";
 
     if ($mode == 'sms') {
       $query = "REPLACE INTO       I_$job_id (phone_id, contact_id)
@@ -377,19 +426,19 @@ WHERE      $mg.entity_table = '$group'
 
       $smartGroupInclude = "
 INSERT IGNORE INTO I_$job_id (email_id, contact_id)
-SELECT     e.id as email_id, c.id as contact_id
+SELECT     civicrm_email.id as email_id, c.id as contact_id
 FROM       civicrm_contact c
-INNER JOIN civicrm_email e                ON e.contact_id         = c.id
+INNER JOIN civicrm_email                ON civicrm_email.contact_id         = c.id
 INNER JOIN civicrm_group_contact_cache gc ON gc.contact_id        = c.id
 LEFT  JOIN X_$job_id                      ON X_$job_id.contact_id = c.id
 WHERE      gc.group_id = {$groupDAO->id}
   AND      c.do_not_email = 0
   AND      c.is_opt_out = 0
   AND      c.is_deceased = 0
-  AND      (e.is_bulkmail = 1 OR e.is_primary = 1)
-  AND      e.on_hold = 0
+  AND      $location_filter
+  AND      civicrm_email.on_hold = 0
   AND      X_$job_id.contact_id IS null
-ORDER BY   e.is_bulkmail
+$order_by
 ";
       if ($mode == 'sms') {
         $smartGroupInclude = "
@@ -450,11 +499,11 @@ AND    $mg.mailing_id = {$mailing_id}
                         AND             $contact.do_not_email = 0
                         AND             $contact.is_opt_out = 0
                         AND             $contact.is_deceased = 0
-                        AND             ($email.is_bulkmail = 1 OR $email.is_primary = 1)
+                        AND             $location_filter
                         AND             $email.on_hold = 0
                         AND             $mg.mailing_id = {$mailing_id}
                         AND             X_$job_id.contact_id IS null
-                    ORDER BY $email.is_bulkmail";
+                    $order_by";
     if ($mode == "sms") {
       $query = "REPLACE INTO       I_$job_id (phone_id, contact_id)
                     SELECT DISTINCT     $phone.id as phone_id,
@@ -574,21 +623,17 @@ ORDER BY   i.contact_id, i.{$tempColumn}
   }
 
   /**
-   *
-   * Returns the regex patterns that are used for preparing the text and html templates
-   *
-   * @access private
-   *
-   **/
+   * Returns the regex patterns that are used for preparing the text and html templates.
+   */
   private function &getPatterns($onlyHrefs = FALSE) {
 
     $patterns = array();
 
-    $protos  = '(https?|ftp)';
+    $protos = '(https?|ftp)';
     $letters = '\w';
-    $gunk    = '\{\}/#~:.?+=&;%@!\,\-';
-    $punc    = '.:?\-';
-    $any     = "{$letters}{$gunk}{$punc}";
+    $gunk = '\{\}/#~:.?+=&;%@!\,\-';
+    $punc = '.:?\-';
+    $any = "{$letters}{$gunk}{$punc}";
     if ($onlyHrefs) {
       $pattern = "\\bhref[ ]*=[ ]*([\"'])?(($protos:[$any]+?(?=[$punc]*[^$any]|$)))([\"'])?";
     }
@@ -600,22 +645,24 @@ ORDER BY   i.contact_id, i.{$tempColumn}
     $patterns[] = '\\\\\{\w+\.\w+\\\\\}|\{\{\w+\.\w+\}\}';
     $patterns[] = '\{\w+\.\w+\}';
 
-    $patterns = '{' . join('|', $patterns) . '}im';
+    $patterns = '{' . implode('|', $patterns) . '}im';
 
     return $patterns;
   }
 
   /**
-   *  returns an array that denotes the type of token that we are dealing with
-   *  we use the type later on when we are doing a token replcement lookup
+   * Returns an array that denotes the type of token that we are dealing with
+   * we use the type later on when we are doing a token replacement lookup
    *
-   *  @param string $token       The token for which we will be doing adata lookup
+   * @param string $token
+   *   The token for which we will be doing adata lookup.
    *
-   *  @return array $funcStruct  An array that holds the token itself and the type.
+   * @return array
+   *   An array that holds the token itself and the type.
    *                             the type will tell us which function to use for the data lookup
    *                             if we need to do a lookup at all
    */
-  function &getDataFunc($token) {
+  public function &getDataFunc($token) {
     static $_categories = NULL;
     static $_categoryString = NULL;
     if (!$_categories) {
@@ -674,26 +721,26 @@ ORDER BY   i.contact_id, i.{$tempColumn}
    * Prepares the text and html templates
    * for generating the emails and returns a copy of the
    * prepared templates
-   *
-   * @access private
-   *
-   **/
+   */
   private function getPreparedTemplates() {
     if (!$this->preparedTemplates) {
-      $patterns['html']    = $this->getPatterns(TRUE);
+      $patterns['html'] = $this->getPatterns(TRUE);
       $patterns['subject'] = $patterns['text'] = $this->getPatterns();
-      $templates           = $this->getTemplates();
+      $templates = $this->getTemplates();
 
       $this->preparedTemplates = array();
 
       foreach (array(
-        'html', 'text', 'subject') as $key) {
+                 'html',
+                 'text',
+                 'subject',
+               ) as $key) {
         if (!isset($templates[$key])) {
           continue;
         }
 
-        $matches        = array();
-        $tokens         = array();
+        $matches = array();
+        $tokens = array();
         $split_template = array();
 
         $email = $templates[$key];
@@ -714,16 +761,13 @@ ORDER BY   i.contact_id, i.{$tempColumn}
   }
 
   /**
+   * Retrieve a ref to an array that holds the email and text templates for this email
+   * assembles the complete template including the header and footer
+   * that the user has uploaded or declared (if they have dome that)
    *
-   *  Retrieve a ref to an array that holds the email and text templates for this email
-   *  assembles the complete template including the header and footer
-   *  that the user has uploaded or declared (if they have dome that)
-   *
-   *
-   * @return array reference to an assoc array
-   * @access private
-   *
-   **/
+   * @return array
+   *   reference to an assoc array
+   */
   private function &getTemplates() {
     if (!$this->templates) {
       $this->getHeaderFooter();
@@ -741,7 +785,7 @@ ORDER BY   i.contact_id, i.{$tempColumn}
           $template[] = $this->footer->body_text;
         }
 
-        $this->templates['text'] = join("\n", $template);
+        $this->templates['text'] = implode("\n", $template);
       }
 
       if ($this->body_html) {
@@ -757,7 +801,7 @@ ORDER BY   i.contact_id, i.{$tempColumn}
           $template[] = $this->footer->body_html;
         }
 
-        $this->templates['html'] = join("\n", $template);
+        $this->templates['html'] = implode("\n", $template);
 
         // this is where we create a text template from the html template if the text template did not exist
         // this way we ensure that every recipient will receive an email even if the pref is set to text and the
@@ -770,7 +814,7 @@ ORDER BY   i.contact_id, i.{$tempColumn}
       if ($this->subject) {
         $template = array();
         $template[] = $this->subject;
-        $this->templates['subject'] = join("\n", $template);
+        $this->templates['subject'] = implode("\n", $template);
       }
     }
     return $this->templates;
@@ -788,10 +832,9 @@ ORDER BY   i.contact_id, i.{$tempColumn}
    *  this function needs to have some sort of a body assigned
    *  either text or html for this to have any meaningful impact
    *
-   * @return array               reference to an assoc array
-   * @access public
-   *
-   **/
+   * @return array
+   *   reference to an assoc array
+   */
   public function &getTokens() {
     if (!$this->tokens) {
 
@@ -822,10 +865,9 @@ ORDER BY   i.contact_id, i.{$tempColumn}
    * Returns the token set for all 3 parts as one set. This allows it to be sent to the
    * hook in one call and standardizes it across other token workflows
    *
-   * @return array               reference to an assoc array
-   * @access public
-   *
-   **/
+   * @return array
+   *   reference to an assoc array
+   */
   public function &getFlattenedTokens() {
     if (!$this->flattenedTokens) {
       $tokens = $this->getTokens();
@@ -847,8 +889,8 @@ ORDER BY   i.contact_id, i.{$tempColumn}
    *  structures to represent the order in which tokens were found from left to right, top to bottom.
    *
    *
-   * @param str $prop     name of the property that holds the text that we want to scan for tokens (html, text)
-   * @access private
+   * @param string $prop name of the property that holds the text that we want to scan for tokens (html, text).
+   *   Name of the property that holds the text that we want to scan for tokens (html, text).
    *
    * @return void
    */
@@ -868,24 +910,24 @@ ORDER BY   i.contact_id, i.{$tempColumn}
   }
 
   /**
-   * Generate an event queue for a test job
-   *
-   * @params array $params contains form values
+   * Generate an event queue for a test job.
    *
-   * @param $testParams
+   * @param array $testParams
+   *   Contains form values.
    *
    * @return void
-   * @access public
    */
   public function getTestRecipients($testParams) {
     if (array_key_exists($testParams['test_group'], CRM_Core_PseudoConstant::group())) {
-      $contacts = civicrm_api('contact','get', array(
-        'version' =>3,
-        'group' => $testParams['test_group'],
-         'return' => 'id',
-           'options' => array('limit' => 100000000000,
-          ))
-       );
+      $contacts = civicrm_api('contact', 'get', array(
+          'version' => 3,
+          'group' => $testParams['test_group'],
+          'return' => 'id',
+          'options' => array(
+            'limit' => 100000000000,
+          ),
+        )
+      );
 
       foreach (array_keys($contacts['values']) as $groupContact) {
         $query = "
@@ -917,12 +959,7 @@ ORDER BY   civicrm_email.is_bulkmail DESC
   }
 
   /**
-   * Retrieve the header and footer for this mailing
-   *
-   * @param void
-   *
-   * @return void
-   * @access private
+   * Load this->header and this->footer.
    */
   private function getHeaderFooter() {
     if (!$this->header and $this->header_id) {
@@ -950,48 +987,58 @@ ORDER BY   civicrm_email.is_bulkmail DESC
    * is placed on the values received, so they do not need to follow the verp
    * convention.
    *
-   * @param array  $headers         Array of message headers to update, in-out
-   * @param string $prefix          Prefix for the message ID, use same prefixes as verp
+   * @param array $headers
+   *   Array of message headers to update, in-out.
+   * @param string $prefix
+   *   Prefix for the message ID, use same prefixes as verp.
    *                                wherever possible
-   * @param string $job_id          Job ID component of the generated message ID
-   * @param string $event_queue_id  Event Queue ID component of the generated message ID
-   * @param string $hash            Hash component of the generated message ID.
+   * @param string $job_id
+   *   Job ID component of the generated message ID.
+   * @param string $event_queue_id
+   *   Event Queue ID component of the generated message ID.
+   * @param string $hash
+   *   Hash component of the generated message ID.
    *
    * @return void
    */
-  static function addMessageIdHeader(&$headers, $prefix, $job_id, $event_queue_id, $hash) {
-    $config           = CRM_Core_Config::singleton();
-    $localpart        = CRM_Core_BAO_MailSettings::defaultLocalpart();
-    $emailDomain      = CRM_Core_BAO_MailSettings::defaultDomain();
+  public static function addMessageIdHeader(&$headers, $prefix, $job_id, $event_queue_id, $hash) {
+    $config = CRM_Core_Config::singleton();
+    $localpart = CRM_Core_BAO_MailSettings::defaultLocalpart();
+    $emailDomain = CRM_Core_BAO_MailSettings::defaultDomain();
     $includeMessageId = CRM_Core_BAO_MailSettings::includeMessageId();
 
     if ($includeMessageId && (!array_key_exists('Message-ID', $headers))) {
       $headers['Message-ID'] = '<' . implode($config->verpSeparator,
-        array(
-          $localpart . $prefix,
-          $job_id,
-          $event_queue_id,
-          $hash,
-        )
-      ) . "@{$emailDomain}>";
+          array(
+            $localpart . $prefix,
+            $job_id,
+            $event_queue_id,
+            $hash,
+          )
+        ) . "@{$emailDomain}>";
     }
   }
 
   /**
-   * static wrapper for getting verp and urls
+   * Static wrapper for getting verp and urls.
    *
-   * @param int $job_id ID of the Job associated with this message
-   * @param int $event_queue_id ID of the EventQueue
-   * @param string $hash Hash of the EventQueue
-   * @param string $email Destination address
+   * @param int $job_id
+   *   ID of the Job associated with this message.
+   * @param int $event_queue_id
+   *   ID of the EventQueue.
+   * @param string $hash
+   *   Hash of the EventQueue.
+   * @param string $email
+   *   Destination address.
    *
-   * @return array (reference) array    array ref that hold array refs to the verp info and urls
+   * @return array
+   *   (reference) array    array ref that hold array refs to the verp info and urls
    */
-  static function getVerpAndUrls($job_id, $event_queue_id, $hash, $email) {
+  public static function getVerpAndUrls($job_id, $event_queue_id, $hash, $email) {
     // create a skeleton object and set its properties that are required by getVerpAndUrlsAndHeaders()
-    $config         = CRM_Core_Config::singleton();
-    $bao            = new CRM_Mailing_BAO_Mailing();
-    $bao->_domain   = CRM_Core_BAO_Domain::getDomain();
+    $config = CRM_Core_Config::singleton();
+    $bao = new CRM_Mailing_BAO_Mailing();
+    $bao->_domain = CRM_Core_BAO_Domain::getDomain();
     $bao->from_name = $bao->from_email = $bao->subject = '';
 
     // use $bao's instance method to get verp and urls
@@ -1000,16 +1047,21 @@ ORDER BY   civicrm_email.is_bulkmail DESC
   }
 
   /**
-   * get verp, urls and headers
+   * Get verp, urls and headers
    *
-   * @param int $job_id ID of the Job associated with this message
-   * @param int $event_queue_id ID of the EventQueue
-   * @param string $hash Hash of the EventQueue
-   * @param string $email Destination address
+   * @param int $job_id
+   *   ID of the Job associated with this message.
+   * @param int $event_queue_id
+   *   ID of the EventQueue.
+   * @param string $hash
+   *   Hash of the EventQueue.
+   * @param string $email
+   *   Destination address.
    *
    * @param bool $isForward
    *
-   * @return array (reference) array    array ref that hold array refs to the verp info, urls, and headers@access private
+   * @return array
+   *   array ref that hold array refs to the verp info, urls, and headers
    */
   private function getVerpAndUrlsAndHeaders($job_id, $event_queue_id, $hash, $email, $isForward = FALSE) {
     $config = CRM_Core_Config::singleton();
@@ -1036,13 +1088,13 @@ ORDER BY   civicrm_email.is_bulkmail DESC
 
     foreach ($verpTokens as $key => $value) {
       $verp[$key] = implode($config->verpSeparator,
-        array(
-          $localpart . $value,
-          $job_id,
-          $event_queue_id,
-          $hash,
-        )
-      ) . "@$emailDomain";
+          array(
+            $localpart . $value,
+            $job_id,
+            $event_queue_id,
+            $hash,
+          )
+        ) . "@$emailDomain";
     }
 
     //handle should override VERP address.
@@ -1092,26 +1144,35 @@ ORDER BY   civicrm_email.is_bulkmail DESC
   }
 
   /**
-   * Compose a message
-   *
-   * @param int $job_id ID of the Job associated with this message
-   * @param int $event_queue_id ID of the EventQueue
-   * @param string $hash Hash of the EventQueue
-   * @param string $contactId ID of the Contact
-   * @param string $email Destination address
-   * @param string $recipient To: of the recipient
-   * @param boolean $test Is this mailing a test?
+   * Compose a message.
+   *
+   * @param int $job_id
+   *   ID of the Job associated with this message.
+   * @param int $event_queue_id
+   *   ID of the EventQueue.
+   * @param string $hash
+   *   Hash of the EventQueue.
+   * @param string $contactId
+   *   ID of the Contact.
+   * @param string $email
+   *   Destination address.
+   * @param string $recipient
+   *   To: of the recipient.
+   * @param bool $test
+   *   Is this mailing a test?.
    * @param $contactDetails
    * @param $attachments
-   * @param boolean $isForward Is this mailing compose for forward?
-   * @param string $fromEmail email address of who is forwardinf it.
+   * @param bool $isForward
+   *   Is this mailing compose for forward?.
+   * @param string $fromEmail
+   *   Email address of who is forwardinf it.
    *
    * @param null $replyToEmail
    *
    * @return Mail_mime               The mail object
-   * @access public
    */
-  public function &compose($job_id, $event_queue_id, $hash, $contactId,
+  public function &compose(
+    $job_id, $event_queue_id, $hash, $contactId,
     $email, &$recipient, $test,
     $contactDetails, &$attachments, $isForward = FALSE,
     $fromEmail = NULL, $replyToEmail = NULL
@@ -1158,8 +1219,8 @@ ORDER BY   civicrm_email.is_bulkmail DESC
 
       if (!$contact || is_a($contact, 'CRM_Core_Error')) {
         CRM_Core_Error::debug_log_message(ts('CiviMail will not send email to a non-existent contact: %1',
-            array(1 => $contactId)
-          ));
+          array(1 => $contactId)
+        ));
         // setting this because function is called by reference
         //@todo test not calling function by reference
         $res = NULL;
@@ -1174,12 +1235,12 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     $pEmails = array();
 
     foreach ($pTemplates as $type => $pTemplate) {
-      $html           = ($type == 'html') ? TRUE : FALSE;
+      $html = ($type == 'html') ? TRUE : FALSE;
       $pEmails[$type] = array();
-      $pEmail         = &$pEmails[$type];
-      $template       = &$pTemplates[$type]['template'];
-      $tokens         = &$pTemplates[$type]['tokens'];
-      $idx            = 0;
+      $pEmail = &$pEmails[$type];
+      $template = &$pTemplates[$type]['template'];
+      $tokens = &$pTemplates[$type]['tokens'];
+      $idx = 0;
       if (!empty($tokens)) {
         foreach ($tokens as $idx => $token) {
           $token_data = $this->getTokenData($token, $html, $contact, $verp, $urls, $event_queue_id);
@@ -1226,8 +1287,9 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     if ($text && ($test || $contact['preferred_mail_format'] == 'Text' ||
         $contact['preferred_mail_format'] == 'Both' ||
         ($contact['preferred_mail_format'] == 'HTML' && !array_key_exists('html', $pEmails))
-      )) {
-      $textBody = join('', $text);
+      )
+    ) {
+      $textBody = implode('', $text);
       if ($useSmarty) {
         $textBody = $smarty->fetch("string:$textBody");
       }
@@ -1236,8 +1298,9 @@ ORDER BY   civicrm_email.is_bulkmail DESC
 
     if ($html && ($test || ($contact['preferred_mail_format'] == 'HTML' ||
           $contact['preferred_mail_format'] == 'Both'
-        ))) {
-      $htmlBody = join('', $html);
+        ))
+    ) {
+      $htmlBody = implode('', $html);
       if ($useSmarty) {
         $htmlBody = $smarty->fetch("string:$htmlBody");
       }
@@ -1248,8 +1311,8 @@ ORDER BY   civicrm_email.is_bulkmail DESC
       // CRM-9833
       // something went wrong, lets log it and return null (by reference)
       CRM_Core_Error::debug_log_message(ts('CiviMail will not send an empty mail body, Skipping: %1',
-          array(1 => $email)
-        ));
+        array(1 => $email)
+      ));
       $res = NULL;
       return $res;
     }
@@ -1258,7 +1321,7 @@ ORDER BY   civicrm_email.is_bulkmail DESC
 
     $mailingSubject = CRM_Utils_Array::value('subject', $pEmails);
     if (is_array($mailingSubject)) {
-      $mailingSubject = join('', $mailingSubject);
+      $mailingSubject = implode('', $mailingSubject);
     }
     $mailParams['Subject'] = $mailingSubject;
 
@@ -1280,7 +1343,13 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     foreach ($mailParams as $paramKey => $paramValue) {
       //exclude values not intended for the header
       if (!in_array($paramKey, array(
-        'text', 'html', 'attachments', 'toName', 'toEmail'))) {
+        'text',
+        'html',
+        'attachments',
+        'toName',
+        'toEmail',
+      ))
+      ) {
         $headers[$paramKey] = $paramValue;
       }
     }
@@ -1345,7 +1414,6 @@ ORDER BY   civicrm_email.is_bulkmail DESC
    *
    * get mailing object and replaces subscribeInvite,
    * domain and mailing tokens
-   *
    */
   public static function tokenReplace(&$mailing) {
     $domain = CRM_Core_BAO_Domain::getDomain();
@@ -1369,12 +1437,11 @@ ORDER BY   civicrm_email.is_bulkmail DESC
    *
    *  getTokenData receives a token from an email
    *  and returns the appropriate data for the token
-   *
    */
   private function getTokenData(&$token_a, $html = FALSE, &$contact, &$verp, &$urls, $event_queue_id) {
-    $type  = $token_a['type'];
+    $type = $token_a['type'];
     $token = $token_a['token'];
-    $data  = $token;
+    $data = $token;
 
     $useSmarty = defined('CIVICRM_MAIL_SMARTY') && CIVICRM_MAIL_SMARTY ? TRUE : FALSE;
 
@@ -1404,6 +1471,9 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     elseif ($type == 'url') {
       if ($this->url_tracking) {
         $data = CRM_Mailing_BAO_TrackableURL::getTrackerURL($token, $this->id, $event_queue_id);
+        if (!empty($html)) {
+          $data = htmlentities($data);
+        }
       }
       else {
         $data = $token;
@@ -1438,16 +1508,16 @@ ORDER BY   civicrm_email.is_bulkmail DESC
    * Return a list of group names for this mailing.  Does not work with
    * prior-mailing targets.
    *
-   * @return array        Names of groups receiving this mailing
-   * @access public
+   * @return array
+   *   Names of groups receiving this mailing
    */
   public function &getGroupNames() {
     if (!isset($this->id)) {
       return array();
     }
-    $mg      = new CRM_Mailing_DAO_MailingGroup();
+    $mg = new CRM_Mailing_DAO_MailingGroup();
     $mgtable = CRM_Mailing_DAO_MailingGroup::getTableName();
-    $group   = CRM_Contact_BAO_Group::getTableName();
+    $group = CRM_Contact_BAO_Group::getTableName();
 
     $mg->query("SELECT      $group.title as name FROM $mgtable
                     INNER JOIN  $group ON $mgtable.entity_id = $group.id
@@ -1465,17 +1535,17 @@ ORDER BY   civicrm_email.is_bulkmail DESC
   }
 
   /**
-   * function to add the mailings
+   * Add the mailings.
    *
-   * @param array $params reference array contains the values submitted by the form
-   * @param array $ids    reference array contains the id
+   * @param array $params
+   *   Reference array contains the values submitted by the form.
+   * @param array $ids
+   *   Reference array contains the id.
    *
-   * @access public
-   * @static
    *
-   * @return object
+   * @return CRM_Mailing_DAO_Mailing
    */
-  static function add(&$params, $ids = array()) {
+  public static function add(&$params, $ids = array()) {
     $id = CRM_Utils_Array::value('mailing_id', $ids, CRM_Utils_Array::value('id', $params));
 
     if ($id) {
@@ -1485,8 +1555,11 @@ ORDER BY   civicrm_email.is_bulkmail DESC
       CRM_Utils_Hook::pre('create', 'Mailing', NULL, $params);
     }
 
-    $mailing            = new CRM_Mailing_DAO_Mailing();
-    $mailing->id        = $id;
+    $mailing = new static();
+    if ($id) {
+      $mailing->id = $id;
+      $mailing->find(TRUE);
+    }
     $mailing->domain_id = CRM_Utils_Array::value('domain_id', $params, CRM_Core_Config::domainID());
 
     if (!isset($params['replyto_email']) &&
@@ -1513,16 +1586,35 @@ ORDER BY   civicrm_email.is_bulkmail DESC
    * Construct a new mailing object, along with job and mailing_group
    * objects, from the form values of the create mailing wizard.
    *
-   * @params array $params        Form values
+   * This function is a bit evil. It not only merges $params and saves
+   * the mailing -- it also schedules the mailing and chooses the recipients.
+   * Since it merges $params, it's also the only place to correctly trigger
+   * multi-field validation. It should be broken up.
+   *
+   * In the mean time, use-cases which break under the weight of this
+   * evil may find reprieve in these extra evil params:
+   *
+   *  - _skip_evil_bao_auto_recipients_: bool
+   *  - _skip_evil_bao_auto_schedule_: bool
+   *  - _evil_bao_validator_: string|callable
+   *
+   * </twowrongsmakesaright>
+   *
+   * @params array $params
+   *   Form values.
    *
-   * @param $params
+   * @param array $params
    * @param array $ids
    *
-   * @return object $mailing      The new mailing object
-   * @access public
-   * @static
+   * @return object
+   *   $mailing      The new mailing object
+   * @throws \Exception
    */
   public static function create(&$params, $ids = array()) {
+    // WTH $ids
+    if (empty($ids) && isset($params['id'])) {
+      $ids['mailing_id'] = $ids['id'] = $params['id'];
+    }
 
     // CRM-12430
     // Do the below only for an insert
@@ -1540,11 +1632,11 @@ ORDER BY   civicrm_email.is_bulkmail DESC
       );
       if (isset($domain['from_email'])) {
         $domain_email = $domain['from_email'];
-        $domain_name  = $domain['from_name'];
+        $domain_name = $domain['from_name'];
       }
       else {
         $domain_email = 'info@EXAMPLE.ORG';
-        $domain_name  = 'EXAMPLE.ORG';
+        $domain_name = 'EXAMPLE.ORG';
       }
       if (!isset($params['created_id'])) {
         $session =& CRM_Core_Session::singleton();
@@ -1554,23 +1646,23 @@ ORDER BY   civicrm_email.is_bulkmail DESC
         // load the default config settings for each
         // eg reply_id, unsubscribe_id need to use
         // correct template IDs here
-        'override_verp'   => TRUE,
+        'override_verp' => TRUE,
         'forward_replies' => FALSE,
-        'open_tracking'   => TRUE,
-        'url_tracking'    => TRUE,
-        'visibility'      => 'Public Pages',
-        'replyto_email'   => $domain_email,
-        'header_id'       => CRM_Mailing_PseudoConstant::defaultComponent('header_id', ''),
-        'footer_id'       => CRM_Mailing_PseudoConstant::defaultComponent('footer_id', ''),
-        'from_email'      => $domain_email,
-        'from_name'       => $domain_name,
+        'open_tracking' => TRUE,
+        'url_tracking' => TRUE,
+        'visibility' => 'Public Pages',
+        'replyto_email' => $domain_email,
+        'header_id' => CRM_Mailing_PseudoConstant::defaultComponent('header_id', ''),
+        'footer_id' => CRM_Mailing_PseudoConstant::defaultComponent('footer_id', ''),
+        'from_email' => $domain_email,
+        'from_name' => $domain_name,
         'msg_template_id' => NULL,
-        'created_id'      => $params['created_id'],
-        'approver_id'     => NULL,
-        'auto_responder'  => 0,
-        'created_date'    => date('YmdHis'),
-        'scheduled_date'  => NULL,
-        'approval_date'   => NULL,
+        'created_id' => $params['created_id'],
+        'approver_id' => NULL,
+        'auto_responder' => 0,
+        'created_date' => date('YmdHis'),
+        'scheduled_date' => NULL,
+        'approval_date' => NULL,
       );
 
       // Get the default from email address, if not provided.
@@ -1613,61 +1705,131 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     $groupTypes = array('include' => 'Include', 'exclude' => 'Exclude', 'base' => 'Base');
     foreach (array('groups', 'mailings') as $entity) {
       foreach (array('include', 'exclude', 'base') as $type) {
-        if (isset($params[$entity]) && !empty($params[$entity][$type]) &&
-          is_array($params[$entity][$type])) {
-          foreach ($params[$entity][$type] as $entityId) {
-            $mg->reset();
-            $mg->mailing_id   = $mailing->id;
-            $mg->entity_table = ($entity == 'groups') ? $groupTableName : $mailingTableName;
-            $mg->entity_id    = $entityId;
-            $mg->group_type   = $groupTypes[$type];
-            $mg->save();
-          }
+        if (isset($params[$entity][$type])) {
+          self::replaceGroups($mailing->id, $groupTypes[$type], $entity, $params[$entity][$type]);
         }
       }
     }
 
     if (!empty($params['search_id']) && !empty($params['group_id'])) {
       $mg->reset();
-      $mg->mailing_id   = $mailing->id;
+      $mg->mailing_id = $mailing->id;
       $mg->entity_table = $groupTableName;
-      $mg->entity_id    = $params['group_id'];
-      $mg->search_id    = $params['search_id'];
-      $mg->search_args  = $params['search_args'];
-      $mg->group_type   = 'Include';
+      $mg->entity_id = $params['group_id'];
+      $mg->search_id = $params['search_id'];
+      $mg->search_args = $params['search_args'];
+      $mg->group_type = 'Include';
       $mg->save();
     }
 
     // check and attach and files as needed
     CRM_Core_BAO_File::processAttachment($params, 'civicrm_mailing', $mailing->id);
 
+    // If we're going to autosend, then check validity before saving.
+    if (!empty($params['scheduled_date']) && $params['scheduled_date'] != 'null' && !empty($params['_evil_bao_validator_'])) {
+      $cb = Civi\Core\Resolver::singleton()->get($params['_evil_bao_validator_']);
+      $errors = call_user_func($cb, $mailing);
+      if (!empty($errors)) {
+        $fields = implode(',', array_keys($errors));
+        throw new CRM_Core_Exception("Mailing cannot be sent. There are missing or invalid fields ($fields).", 'cannot-send', $errors);
+      }
+    }
+
     $transaction->commit();
 
-    /**
-     * create parent job if not yet created
-     * condition on the existence of a scheduled date
-     */
-    if (!empty($params['scheduled_date']) && $params['scheduled_date'] != 'null') {
+    // Create parent job if not yet created.
+    // Condition on the existence of a scheduled date.
+    if (!empty($params['scheduled_date']) && $params['scheduled_date'] != 'null' && empty($params['_skip_evil_bao_auto_schedule_'])) {
       $job = new CRM_Mailing_BAO_MailingJob();
       $job->mailing_id = $mailing->id;
       $job->status = 'Scheduled';
       $job->is_test = 0;
 
-      if ( !$job->find(TRUE) ) {
+      if (!$job->find(TRUE)) {
         $job->scheduled_date = $params['scheduled_date'];
         $job->save();
       }
 
       // Populate the recipients.
-      $mailing->getRecipients($job->id, $mailing->id, NULL, NULL, TRUE, FALSE);
+      if (empty($params['_skip_evil_bao_auto_recipients_'])) {
+        self::getRecipients($job->id, $mailing->id, NULL, NULL, TRUE, FALSE);
+      }
     }
 
     return $mailing;
   }
 
   /**
-   * get hash value of the mailing
+   * @param CRM_Mailing_DAO_Mailing $mailing
+   *   The mailing which may or may not be sendable.
+   * @return array
+   *   List of error messages.
+   */
+  public static function checkSendable($mailing) {
+    $errors = array();
+    foreach (array('subject', 'name', 'from_name', 'from_email') as $field) {
+      if (empty($mailing->{$field})) {
+        $errors[$field] = ts('Field "%1" is required.', array(
+          1 => $field,
+        ));
+      }
+    }
+    if (empty($mailing->body_html) && empty($mailing->body_text)) {
+      $errors['body'] = ts('Field "body_html" or "body_text" is required.');
+    }
+
+    if (!CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::MAILING_PREFERENCES_NAME, 'disable_mandatory_tokens_check')) {
+      $header = $mailing->header_id && $mailing->header_id != 'null' ? CRM_Mailing_BAO_Component::findById($mailing->header_id) : NULL;
+      $footer = $mailing->footer_id && $mailing->footer_id != 'null' ? CRM_Mailing_BAO_Component::findById($mailing->footer_id) : NULL;
+      foreach (array('body_html', 'body_text') as $field) {
+        if (empty($mailing->{$field})) {
+          continue;
+        }
+        $str = ($header ? $header->{$field} : '') . $mailing->{$field} . ($footer ? $footer->{$field} : '');
+        $err = CRM_Utils_Token::requiredTokens($str);
+        if ($err !== TRUE) {
+          foreach ($err as $token => $desc) {
+            $errors["{$field}:{$token}"] = ts('This message is missing a required token - {%1}: %2',
+              array(1 => $token, 2 => $desc)
+            );
+          }
+        }
+      }
+    }
+
+    return $errors;
+  }
+
+  /**
+   * Replace the list of recipients on a given mailing.
+   *
+   * @param int $mailingId
+   * @param string $type
+   *   'include' or 'exclude'.
+   * @param string $entity
+   *   'groups' or 'mailings'.
+   * @param array <int> $entityIds
+   * @throws CiviCRM_API3_Exception
+   */
+  public static function replaceGroups($mailingId, $type, $entity, $entityIds) {
+    $values = array();
+    foreach ($entityIds as $entityId) {
+      $values[] = array('entity_id' => $entityId);
+    }
+    civicrm_api3('mailing_group', 'replace', array(
+      'mailing_id' => $mailingId,
+      'group_type' => $type,
+      'entity_table' => ($entity == 'groups') ? CRM_Contact_BAO_Group::getTableName() : CRM_Mailing_BAO_Mailing::getTableName(),
+      'values' => $values,
+    ));
+  }
+
+  /**
+   * Get hash value of the mailing.
+   *
+   * @param $id
    *
+   * @return null|string
    */
   public static function getMailingHash($id) {
     $hash = NULL;
@@ -1681,14 +1843,15 @@ ORDER BY   civicrm_email.is_bulkmail DESC
    * Generate a report.  Fetch event count information, mailing data, and job
    * status.
    *
-   * @param int $id The mailing id to report
-   * @param boolean $skipDetails whether return all detailed report
+   * @param int $id
+   *   The mailing id to report.
+   * @param bool $skipDetails
+   *   Whether return all detailed report.
    *
    * @param bool $isSMS
    *
-   * @return array        Associative array of reporting data
-   * @access public
-   * @static
+   * @return array
+   *   Associative array of reporting data
    */
   public static function &report($id, $skipDetails = FALSE, $isSMS = FALSE) {
     $mailing_id = CRM_Utils_Type::escape($id, 'Integer');
@@ -1704,18 +1867,15 @@ ORDER BY   civicrm_email.is_bulkmail DESC
       'delivered' => CRM_Mailing_Event_BAO_Delivered::getTableName(),
       'opened' => CRM_Mailing_Event_BAO_Opened::getTableName(),
       'reply' => CRM_Mailing_Event_BAO_Reply::getTableName(),
-      'unsubscribe' =>
-      CRM_Mailing_Event_BAO_Unsubscribe::getTableName(),
+      'unsubscribe' => CRM_Mailing_Event_BAO_Unsubscribe::getTableName(),
       'bounce' => CRM_Mailing_Event_BAO_Bounce::getTableName(),
       'forward' => CRM_Mailing_Event_BAO_Forward::getTableName(),
       'url' => CRM_Mailing_BAO_TrackableURL::getTableName(),
-      'urlopen' =>
-      CRM_Mailing_Event_BAO_TrackableURLOpen::getTableName(),
+      'urlopen' => CRM_Mailing_Event_BAO_TrackableURLOpen::getTableName(),
       'component' => CRM_Mailing_BAO_Component::getTableName(),
       'spool' => CRM_Mailing_BAO_Spool::getTableName(),
     );
 
-
     $report = array();
     $additionalWhereClause = " AND ";
     if (!$isSMS) {
@@ -1780,8 +1940,7 @@ ORDER BY   civicrm_email.is_bulkmail DESC
       $report['component'][] = array(
         'type' => $components[$mailing->type],
         'name' => $mailing->name,
-        'link' =>
-        CRM_Utils_System::url('civicrm/mailing/component',
+        'link' => CRM_Utils_System::url('civicrm/mailing/component',
           "reset=1&action=update&id={$mailing->id}"
         ),
       );
@@ -1814,18 +1973,18 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     while ($mailing->fetch()) {
       $row = array();
       if (isset($mailing->group_id)) {
-        $row['id']   = $mailing->group_id;
+        $row['id'] = $mailing->group_id;
         $row['name'] = $mailing->group_title;
         $row['link'] = CRM_Utils_System::url('civicrm/group/search',
-                       "reset=1&force=1&context=smog&gid={$row['id']}"
+          "reset=1&force=1&context=smog&gid={$row['id']}"
         );
       }
       else {
-        $row['id']      = $mailing->mailing_id;
-        $row['name']    = $mailing->mailing_name;
+        $row['id'] = $mailing->mailing_id;
+        $row['name'] = $mailing->mailing_name;
         $row['mailing'] = TRUE;
-        $row['link']    = CRM_Utils_System::url('civicrm/mailing/report',
-                          "mid={$row['id']}"
+        $row['link'] = CRM_Utils_System::url('civicrm/mailing/report',
+          "mid={$row['id']}"
         );
       }
 
@@ -1880,8 +2039,16 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     $report['jobs'] = array();
     $report['event_totals'] = array();
     $elements = array(
-      'queue', 'delivered', 'url', 'forward',
-      'reply', 'unsubscribe', 'optout', 'opened', 'bounce', 'spool',
+      'queue',
+      'delivered',
+      'url',
+      'forward',
+      'reply',
+      'unsubscribe',
+      'optout',
+      'opened',
+      'bounce',
+      'spool',
     );
 
     // initialize various counters
@@ -1964,7 +2131,10 @@ ORDER BY   civicrm_email.is_bulkmail DESC
       );
 
       foreach (array(
-          'scheduled_date', 'start_date', 'end_date') as $key) {
+                 'scheduled_date',
+                 'start_date',
+                 'end_date',
+               ) as $key) {
         $row[$key] = CRM_Utils_Date::customFormat($row[$key]);
       }
       $report['jobs'][] = $row;
@@ -2017,13 +2187,11 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     while ($mailing->fetch()) {
       $report['click_through'][] = array(
         'url' => $mailing->url,
-        'link' =>
-        CRM_Utils_System::url(
+        'link' => CRM_Utils_System::url(
           'civicrm/mailing/report/event',
           "reset=1&event=click&mid=$mailing_id&uid={$mailing->id}"
         ),
-        'link_unique' =>
-        CRM_Utils_System::url(
+        'link_unique' => CRM_Utils_System::url(
           'civicrm/mailing/report/event',
           "reset=1&event=click&mid=$mailing_id&uid={$mailing->id}&distinct=1"
         ),
@@ -2076,23 +2244,29 @@ ORDER BY   civicrm_email.is_bulkmail DESC
       ),
     );
 
-
     $actionLinks = array(CRM_Core_Action::VIEW => array('name' => ts('Report')));
     if (CRM_Core_Permission::check('view all contacts')) {
-      $actionLinks[CRM_Core_Action::ADVANCED] =
-        array(
-          'name' => ts('Advanced Search'),
-          'url' => 'civicrm/contact/search/advanced',
-        );
+      $actionLinks[CRM_Core_Action::ADVANCED] = array(
+        'name' => ts('Advanced Search'),
+        'url' => 'civicrm/contact/search/advanced',
+      );
     }
     $action = array_sum(array_keys($actionLinks));
 
     $report['event_totals']['actionlinks'] = array();
     foreach (array(
-        'clicks', 'clicks_unique', 'queue', 'delivered', 'bounce', 'unsubscribe',
-        'forward', 'reply', 'opened', 'optout',
-      ) as $key) {
-      $url          = 'mailing/detail';
+               'clicks',
+               'clicks_unique',
+               'queue',
+               'delivered',
+               'bounce',
+               'unsubscribe',
+               'forward',
+               'reply',
+               'opened',
+               'optout',
+             ) as $key) {
+      $url = 'mailing/detail';
       $reportFilter = "reset=1&mailing_id_value={$mailing_id}";
       $searchFilter = "force=1&mailing_id=%%mid%%";
       switch ($key) {
@@ -2157,12 +2331,12 @@ ORDER BY   civicrm_email.is_bulkmail DESC
   }
 
   /**
-   * Get the count of mailings
+   * Get the count of mailings.
    *
    * @param
    *
-   * @return int              Count
-   * @access public
+   * @return int
+   *   Count
    */
   public function getCount() {
     $this->selectAdd();
@@ -2175,11 +2349,11 @@ ORDER BY   civicrm_email.is_bulkmail DESC
   }
 
   /**
-   * @param $id
+   * @param int $id
    *
    * @throws Exception
    */
-  static function checkPermission($id) {
+  public static function checkPermission($id) {
     if (!$id) {
       return;
     }
@@ -2192,7 +2366,6 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     if (!in_array($id, $mailingIDs)) {
       CRM_Core_Error::fatal(ts('You do not have permission to access this mailing report'));
     }
-    return;
   }
 
   /**
@@ -2200,7 +2373,7 @@ ORDER BY   civicrm_email.is_bulkmail DESC
    *
    * @return string
    */
-  static function mailingACL($alias = NULL) {
+  public static function mailingACL($alias = NULL) {
     $mailingACL = " ( 0 ) ";
 
     $mailingIDs = self::mailingACLIDs();
@@ -2210,23 +2383,23 @@ ORDER BY   civicrm_email.is_bulkmail DESC
 
     if (!empty($mailingIDs)) {
       $mailingIDs = implode(',', $mailingIDs);
-      $tableName  = !$alias ? self::getTableName() : $alias;
+      $tableName = !$alias ? self::getTableName() : $alias;
       $mailingACL = " $tableName.id IN ( $mailingIDs ) ";
     }
     return $mailingACL;
   }
 
   /**
-   * returns all the mailings that this user can access. This is dependent on
+   * Returns all the mailings that this user can access. This is dependent on
    * all the groups that the user has access to.
    * However since most civi installs dont use ACL's we special case the condition
    * where the user has access to ALL groups, and hence ALL mailings and return a
    * value of TRUE (to avoid the downstream where clause with a list of mailing list IDs
    *
-   * @return boolean | array - TRUE if the user has access to all mailings, else array of mailing IDs (possibly empty)
-   * @static
+   * @return bool|array
+   *   TRUE if the user has access to all mailings, else array of mailing IDs (possibly empty).
    */
-  static function mailingACLIDs() {
+  public static function mailingACLIDs() {
     // CRM-11633
     // optimize common case where admin has access
     // to all mailings
@@ -2265,22 +2438,25 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
   }
 
   /**
-   * Get the rows for a browse operation
+   * Get the rows for a browse operation.
    *
-   * @param int $offset The row number to start from
-   * @param int $rowCount The nmber of rows to return
-   * @param string $sort The sql string that describes the sort order
+   * @param int $offset
+   *   The row number to start from.
+   * @param int $rowCount
+   *   The nmber of rows to return.
+   * @param string $sort
+   *   The sql string that describes the sort order.
    *
    * @param null $additionalClause
-   * @param null $additionalParams
+   * @param array $additionalParams
    *
-   * @return array            The rows
-   * @access public
+   * @return array
+   *   The rows
    */
   public function &getRows($offset, $rowCount, $sort, $additionalClause = NULL, $additionalParams = NULL) {
     $mailing = self::getTableName();
-    $job     = CRM_Mailing_BAO_MailingJob::getTableName();
-    $group   = CRM_Mailing_DAO_MailingGroup::getTableName();
+    $job = CRM_Mailing_BAO_MailingJob::getTableName();
+    $group = CRM_Mailing_DAO_MailingGroup::getTableName();
     $session = CRM_Core_Session::singleton();
 
     $mailingACL = self::mailingACL();
@@ -2359,26 +2535,23 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
   }
 
   /**
-   * Function to show detail Mailing report
+   * Show detail Mailing report.
    *
    * @param int $id
    *
    * @return string
-   * @static
-   * @access public
    */
-  static function showEmailDetails($id) {
+  public static function showEmailDetails($id) {
     return CRM_Utils_System::url('civicrm/mailing/report', "mid=$id");
   }
 
   /**
-   * Delete Mails and all its associated records
+   * Delete Mails and all its associated records.
    *
-   * @param  int  $id id of the mail to delete
+   * @param int $id
+   *   Id of the mail to delete.
    *
    * @return void
-   * @access public
-   * @static
    */
   public static function del($id) {
     if (empty($id)) {
@@ -2405,11 +2578,10 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
    * Delete Jobss and all its associated records
    * related to test Mailings
    *
-   * @param  int  $id id of the Job to delete
+   * @param int $id
+   *   Id of the Job to delete.
    *
    * @return void
-   * @access public
-   * @static
    */
   public static function delJob($id) {
     if (empty($id)) {
@@ -2424,7 +2596,7 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
   /**
    * @return array
    */
-  function getReturnProperties() {
+  public function getReturnProperties() {
     $tokens = &$this->getTokens();
 
     $properties = array();
@@ -2457,12 +2629,11 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
   }
 
   /**
-   * Function to build the  compose mail form
+   * Build the  compose mail form.
    *
-   * @param   $form
+   * @param CRM_Core_Form $form
    *
    * @return void
-   * @access public
    */
   public static function commonCompose(&$form) {
     //get the tokens.
@@ -2486,16 +2657,17 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
 
     $templates = array();
 
-    $textFields = array('text_message' => ts('HTML format'), 'sms_text_message' => ts('SMS Message'));
+    $textFields = array('text_message' => ts('HTML Format'), 'sms_text_message' => ts('SMS Message'));
     $modePrefixes = array('Mail' => NULL, 'SMS' => 'SMS');
 
     if ($className != 'CRM_SMS_Form_Upload' && $className != 'CRM_Contact_Form_Task_SMS' &&
       $className != 'CRM_Contact_Form_Task_SMS'
     ) {
       $form->addWysiwyg('html_message',
-        ts('HTML format'),
+        ts('HTML Format'),
         array(
-          'cols' => '80', 'rows' => '8',
+          'cols' => '80',
+          'rows' => '8',
           'onkeyup' => "return verify(this)",
         )
       );
@@ -2518,7 +2690,8 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
       }
       $form->add('textarea', $id, $label,
         array(
-          'cols' => '80', 'rows' => '8',
+          'cols' => '80',
+          'rows' => '8',
           'onkeyup' => "return verify(this, '{$prefix}')",
         )
       );
@@ -2550,12 +2723,11 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
   }
 
   /**
-   * Function to build the  compose PDF letter form
+   * Build the  compose PDF letter form.
    *
-   * @param   $form
+   * @param CRM_Core_Form $form
    *
    * @return void
-   * @access public
    */
   public static function commonLetterCompose(&$form) {
     //get the tokens.
@@ -2568,7 +2740,7 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
       $tokens = array_merge(CRM_Core_SelectValues::contributionTokens(), $tokens);
     }
 
-    if(method_exists($form, 'listTokens')) {
+    if (method_exists($form, 'listTokens')) {
       $tokens = array_merge($form->listTokens(), $tokens);
     }
 
@@ -2579,7 +2751,8 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
       $form->assign('templates', TRUE);
       $form->add('select', 'template', ts('Select Template'),
         array(
-          '' => ts('- select -')) + $form->_templates, FALSE,
+          '' => ts('- select -'),
+        ) + $form->_templates, FALSE,
         array('onChange' => "selectValue( this.value,'' );")
       );
       $form->add('checkbox', 'updateTemplate', ts('Update Template'), NULL);
@@ -2590,11 +2763,11 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
     );
     $form->add('text', 'saveTemplateName', ts('Template Title'));
 
-
     $form->addWysiwyg('html_message',
       ts('Your Letter'),
       array(
-        'cols' => '80', 'rows' => '8',
+        'cols' => '80',
+        'rows' => '8',
         'onkeyup' => "return verify(this)",
       )
     );
@@ -2607,10 +2780,10 @@ LEFT JOIN civicrm_mailing_group g ON g.mailing_id   = m.id
   }
 
   /**
-   * Get the search based mailing Ids
+   * Get the search based mailing Ids.
    *
-   * @return array $mailingIDs, searched base mailing ids.
-   * @access public
+   * @return array
+   *   , searched base mailing ids.
    */
   public function searchMailingIDs() {
     $group = CRM_Mailing_DAO_MailingGroup::getTableName();
@@ -2634,15 +2807,18 @@ SELECT  $mailing.id as mailing_id
   /**
    * Get the content/components of mailing based on mailing Id
    *
-   * @param $report array of mailing report
+   * @param array $report
+   *   of mailing report.
    *
-   * @param $form reference of this
+   * @param $form
+   *   Reference of this.
    *
    * @param bool $isSMS
    *
-   * @return array $report array content/component.@access public
+   * @return array
+   *   array content/component.
    */
-  static function getMailingContent(&$report, &$form, $isSMS = FALSE) {
+  public static function getMailingContent(&$report, &$form, $isSMS = FALSE) {
     $htmlHeader = $textHeader = NULL;
     $htmlFooter = $textFooter = NULL;
 
@@ -2690,11 +2866,11 @@ SELECT  $mailing.id as mailing_id
   }
 
   /**
-   * @param $jobID
+   * @param int $jobID
    *
    * @return mixed
    */
-  static function overrideVerp($jobID) {
+  public static function overrideVerp($jobID) {
     static $_cache = array();
 
     if (!isset($_cache[$jobID])) {
@@ -2716,12 +2892,14 @@ WHERE  civicrm_mailing_job.id = %1
    * @return bool
    * @throws Exception
    */
-  static function processQueue($mode = NULL) {
+  public static function processQueue($mode = NULL) {
     $config = &CRM_Core_Config::singleton();
-    //   CRM_Core_Error::debug_log_message("Beginning processQueue run: {$config->mailerJobsMax}, {$config->mailerJobSize}");
 
     if ($mode == NULL && CRM_Core_BAO_MailSettings::defaultDomain() == "EXAMPLE.ORG") {
-      throw new CRM_Core_Exception(ts('The <a href="%1">default mailbox</a> has not been configured. You will find <a href="%2">more info in the online user and administrator guide</a>', array(1 => CRM_Utils_System::url('civicrm/admin/mailSettings', 'reset=1'), 2 => "http://book.civicrm.org/user/advanced-configuration/email-system-configuration/")));
+      throw new CRM_Core_Exception(ts('The <a href="%1">default mailbox</a> has not been configured. You will find <a href="%2">more info in the online user and administrator guide</a>', array(
+            1 => CRM_Utils_System::url('civicrm/admin/mailSettings', 'reset=1'),
+            2 => "http://book.civicrm.org/user/advanced-configuration/email-system-configuration/",
+          )));
     }
 
     // check if we are enforcing number of parallel cron jobs
@@ -2765,12 +2943,11 @@ WHERE  civicrm_mailing_job.id = %1
       $cronLock->release();
     }
 
- //   CRM_Core_Error::debug_log_message('Ending processQueue run');
     return TRUE;
   }
 
   /**
-   * @param $mailingID
+   * @param int $mailingID
    */
   private static function addMultipleEmails($mailingID) {
     $sql = "
@@ -2793,7 +2970,7 @@ AND    e.id NOT IN ( SELECT email_id FROM civicrm_mailing_recipients mr WHERE ma
    *
    * @return mixed
    */
-  static function getMailingsList($isSMS = FALSE) {
+  public static function getMailingsList($isSMS = FALSE) {
     static $list = array();
     $where = " WHERE ";
     if (!$isSMS) {
@@ -2820,11 +2997,11 @@ ORDER BY civicrm_mailing.name";
   }
 
   /**
-   * @param $mid
+   * @param int $mid
    *
    * @return null|string
    */
-  static function hiddenMailingGroup($mid) {
+  public static function hiddenMailingGroup($mid) {
     $sql = "
 SELECT     g.id
 FROM       civicrm_mailing m
@@ -2834,24 +3011,25 @@ WHERE      g.is_hidden = 1
 AND        mg.group_type = 'Include'
 AND        m.id = %1
 ";
-    $params = array( 1 => array( $mid, 'Integer' ) );
+    $params = array(1 => array($mid, 'Integer'));
     return CRM_Core_DAO::singleValueQuery($sql, $params);
   }
 
   /**
-   * This function is a wrapper for ajax activity selector
+   * wrapper for ajax activity selector.
    *
-   * @param  array   $params associated array for params record id.
+   * @param array $params
+   *   Associated array for params record id.
    *
-   * @return array   $contactActivities associated array of contact activities
-   * @access public
+   * @return array
+   *   associated array of contact activities
    */
   public static function getContactMailingSelector(&$params) {
     // format the params
-    $params['offset']   = ($params['page'] - 1) * $params['rp'];
+    $params['offset'] = ($params['page'] - 1) * $params['rp'];
     $params['rowCount'] = $params['rp'];
-    $params['sort']     = CRM_Utils_Array::value('sortBy', $params);
-    $params['caseId']   = NULL;
+    $params['sort'] = CRM_Utils_Array::value('sortBy', $params);
+    $params['caseId'] = NULL;
 
     // get contact mailings
     $mailings = CRM_Mailing_BAO_Mailing::getContactMailings($params);
@@ -2879,9 +3057,9 @@ AND        m.id = %1
         "reset=1&cid={$values['creator_id']}");
 
       //CRM-12814
-      $contactMailings[$mailingId]['openstats'] = "Opens: ".
-        CRM_Utils_Array::value($values['mailing_id'], $openCounts, 0).
-        "<br />Clicks: ".
+      $contactMailings[$mailingId]['openstats'] = "Opens: " .
+        CRM_Utils_Array::value($values['mailing_id'], $openCounts, 0) .
+        "<br />Clicks: " .
         CRM_Utils_Array::value($values['mailing_id'], $clickCounts, 0);
 
       $actionLinks = array(
@@ -2897,7 +3075,7 @@ AND        m.id = %1
           'url' => 'civicrm/mailing/report',
           'qs' => "mid=%%mid%%&reset=1&cid=%%cid%%&context=mailing",
           'title' => ts('View Mailing Report'),
-        )
+        ),
       );
 
       $mailingKey = $values['mailing_id'];
@@ -2907,7 +3085,7 @@ AND        m.id = %1
 
       $contactMailings[$mailingId]['links'] = CRM_Core_Action::formLink(
         $actionLinks,
-        null,
+        NULL,
         array(
           'mid' => $values['mailing_id'],
           'cid' => $params['contact_id'],
@@ -2925,37 +3103,71 @@ AND        m.id = %1
   }
 
   /**
-   * Function to retrieve contact mailing
+   * Retrieve contact mailing.
    *
-   * @param array $params associated array
+   * @param array $params
    *
-   * @return array of mailings for a contact
+   * @return array
+   *   Array of mailings for a contact
    *
-   * @static
-   * @access public
    */
   static public function getContactMailings(&$params) {
     $params['version'] = 3;
-    $params['offset']  = ($params['page'] - 1) * $params['rp'];
-    $params['limit']   = $params['rp'];
-    $params['sort']    = CRM_Utils_Array::value('sortBy', $params);
+    $params['offset'] = ($params['page'] - 1) * $params['rp'];
+    $params['limit'] = $params['rp'];
+    $params['sort'] = CRM_Utils_Array::value('sortBy', $params);
 
     $result = civicrm_api('MailingContact', 'get', $params);
     return $result['values'];
   }
 
   /**
-   * Function to retrieve contact mailing count
+   * Retrieve contact mailing count.
    *
-   * @param array $params associated array
+   * @param array $params
    *
-   * @return int count of mailings for a contact
+   * @return int
+   *   count of mailings for a contact
    *
-   * @static
-   * @access public
    */
   static public function getContactMailingsCount(&$params) {
     $params['version'] = 3;
     return civicrm_api('MailingContact', 'getcount', $params);
   }
+
+  /**
+   * Get a list of permissions required for CRUD'ing each field
+   * (when workflow is enabled).
+   *
+   * @return array
+   *   Array (string $fieldName => string $permName)
+   */
+  public static function getWorkflowFieldPerms() {
+    $fieldNames = array_keys(CRM_Mailing_DAO_Mailing::fields());
+    $fieldPerms = array();
+    foreach ($fieldNames as $fieldName) {
+      if ($fieldName == 'id') {
+        $fieldPerms[$fieldName] = array(
+          array('access CiviMail', 'schedule mailings', 'approve mailings', 'create mailings'), // OR
+        );
+      }
+      elseif (in_array($fieldName, array('scheduled_date', 'scheduled_id'))) {
+        $fieldPerms[$fieldName] = array(
+          array('access CiviMail', 'schedule mailings'), // OR
+        );
+      }
+      elseif (in_array($fieldName, array('approval_date', 'approver_id', 'approval_status_id', 'approval_note'))) {
+        $fieldPerms[$fieldName] = array(
+          array('access CiviMail', 'approve mailings'), // OR
+        );
+      }
+      else {
+        $fieldPerms[$fieldName] = array(
+          array('access CiviMail', 'create mailings'), // OR
+        );
+      }
+    }
+    return $fieldPerms;
+  }
+
 }