--- /dev/null
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+/**
+ * @service
+ * @internal
+ */
+class MailingGetSpecProvider extends \Civi\Core\Service\AutoService implements Generic\SpecProviderInterface {
+
+ /**
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ *
+ * @throws \CRM_Core_Exception
+ */
+ public function modifySpec(RequestSpec $spec): void {
+ $field = new FieldSpec('stats_intended_recipients', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Intended Recipients'))
+ ->setDescription(ts('Total emails sent'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countIntendedRecipients']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_successful', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Succesful Deliveries'))
+ ->setDescription(ts('Total emails delivered minus bounces'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countSuccessfulDeliveries']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_opens_total', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Total Opens'))
+ ->setDescription(ts('Total tracked mailing opens'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_opens_unique', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Unique Opens'))
+ ->setDescription(ts('Total unique tracked mailing opens'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_clicks_total', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Total Clicks'))
+ ->setDescription(ts('Total mailing clicks'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_clicks_unique', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Unique Clicks'))
+ ->setDescription(ts('Total unique mailing clicks'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_bounces', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Bounces'))
+ ->setDescription(ts('Total mailing bounces'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_unsubscribes', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Unsubscribes'))
+ ->setDescription(ts('Total mailing unsubscribes'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_optouts', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Opt Outs'))
+ ->setDescription(ts('Total mailing opt outs'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_forwards', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Forwards'))
+ ->setDescription(ts('Total mailing forwards'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+
+ $field = new FieldSpec('stats_replies', 'Mailing', 'Integer');
+ $field->setLabel(ts('Stats: Replies'))
+ ->setDescription(ts('Total mailing replies'))
+ ->setColumnName('id')
+ ->setReadonly(TRUE)
+ ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+ $spec->addFieldSpec($field);
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action): bool {
+ return $entity === 'Mailing' && $action === 'get';
+ }
+
+ /**
+ * Generate SQL for counting mailing events
+ *
+ * @return string
+ */
+ public static function countMailingEvents(array $field): string {
+ $unsubscribeType = $count = NULL;
+ $queue = \CRM_Mailing_Event_BAO_MailingEventQueue::getTableName();
+ $job = \CRM_Mailing_BAO_MailingJob::getTableName();
+ $mailing = \CRM_Mailing_BAO_Mailing::getTableName();
+
+ switch ($field['name']) {
+ case 'stats_opens_total':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventOpened::getTableName();
+ break;
+
+ case 'stats_opens_unique':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventOpened::getTableName();
+ $count = "DISTINCT $tableName.event_queue_id";
+ break;
+
+ case 'stats_clicks_total':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventTrackableURLOpen::getTableName();
+ break;
+
+ case 'stats_clicks_unique':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventTrackableURLOpen::getTableName();
+ $count = "DISTINCT $tableName.event_queue_id,$tableName.trackable_url_id";
+ break;
+
+ case 'stats_bounces':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventBounce::getTableName();
+ break;
+
+ case 'stats_unsubscribes':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventUnsubscribe::getTableName();
+ $unsubscribeType = 0;
+ $count = "DISTINCT $tableName.event_queue_id,$tableName.org_unsubscribe";
+ break;
+
+ case 'stats_optouts':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventUnsubscribe::getTableName();
+ $unsubscribeType = 1;
+ $count = "DISTINCT $tableName.event_queue_id,$tableName.org_unsubscribe";
+ break;
+
+ case 'stats_forwards':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventForward::getTableName();
+ break;
+
+ case 'stats_replies':
+ $tableName = \CRM_Mailing_Event_BAO_MailingEventReply::getTableName();
+ break;
+
+ }
+
+ $count = $count ?? "$tableName.event_queue_id";
+ $query = "(
+ SELECT COUNT($count)
+ FROM $tableName
+ INNER JOIN $queue
+ ON $tableName.event_queue_id = $queue.id
+ INNER JOIN $job
+ ON $queue.job_id = $job.id
+ INNER JOIN $mailing
+ ON $job.mailing_id = $mailing.id
+ AND $job.is_test = 0
+ WHERE $mailing.id = {$field['sql_name']}
+ ";
+ if (!is_null($unsubscribeType)) {
+ $query .= " AND $tableName.org_unsubscribe = $unsubscribeType";
+ }
+ return $query . ")";
+ }
+
+ /**
+ * Generate SQL for counting total intended recipients
+ *
+ * @return string
+ */
+ public static function countIntendedRecipients(array $field): string {
+ $queue = \CRM_Mailing_Event_BAO_MailingEventQueue::getTableName();
+ $mailing = \CRM_Mailing_BAO_Mailing::getTableName();
+ $job = \CRM_Mailing_BAO_MailingJob::getTableName();
+
+ return "(
+ SELECT COUNT($queue.id)
+ FROM $queue
+ INNER JOIN $job
+ ON $queue.job_id = $job.id
+ INNER JOIN $mailing
+ ON $job.mailing_id = $mailing.id
+ AND $job.is_test = 0
+ WHERE $mailing.id = {$field['sql_name']}
+ )";
+ }
+
+ /**
+ * Generate SQL for counting total successful deliveries
+ *
+ * @return string
+ */
+ public static function countSuccessfulDeliveries(array $field): string {
+ $delivered = \CRM_Mailing_Event_BAO_MailingEventDelivered::getTableName();
+ $bounce = \CRM_Mailing_Event_BAO_MailingEventBounce::getTableName();
+ $queue = \CRM_Mailing_Event_BAO_MailingEventQueue::getTableName();
+ $mailing = \CRM_Mailing_BAO_Mailing::getTableName();
+ $job = \CRM_Mailing_BAO_MailingJob::getTableName();
+
+ return "(
+ SELECT COUNT($delivered.id)
+ FROM $delivered
+ INNER JOIN $queue
+ ON $delivered.event_queue_id = $queue.id
+ LEFT JOIN $bounce
+ ON $delivered.event_queue_id = $bounce.event_queue_id
+ INNER JOIN $job
+ ON $queue.job_id = $job.id
+ AND $job.is_test = 0
+ INNER JOIN $mailing
+ ON $job.mailing_id = $mailing.id
+ WHERE $bounce.id IS null
+ AND $mailing.id = {$field['sql_name']}
+ )";
+ }
+
+}
--- /dev/null
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+namespace api\v4\Entity;
+
+use api\v4\Api4TestBase;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class MailingEventTest extends Api4TestBase implements TransactionalInterface {
+
+ public function testMailingStats() {
+ $cid1 = $this->createTestRecord('Contact')['id'];
+ $cid2 = $this->createTestRecord('Contact')['id'];
+ $eid1 = $this->createTestRecord('Email', ['contact_id' => $cid1])['id'];
+ $eid2 = $this->createTestRecord('Email', ['contact_id' => $cid2])['id'];
+ $mid1 = $this->createTestRecord('Mailing')['id'];
+ $mid2 = $this->createTestRecord('Mailing')['id'];
+ $parentJobIDs = $this->saveTestRecords('MailingJob',
+ [
+ 'records' => [
+ ['mailing_id' => $mid1, 'is_test' => 'false'],
+ ['mailing_id' => $mid2, 'is_test' => 'false'],
+ ],
+ ])->column('id');
+
+ $childJobIDs = $this->saveTestRecords('MailingJob',
+ [
+ 'records' => [
+ ['mailing_id' => $mid1, 'parent_id' => $parentJobIDs[0], 'job_type' => 'child', 'is_test' => 'false'],
+ ['mailing_id' => $mid2, 'parent_id' => $parentJobIDs[1], 'job_type' => 'child', 'is_test' => 'false'],
+ ],
+ ])->column('id');
+
+ $queueIDs = $this->saveTestRecords('MailingEventQueue',
+ [
+ 'records' => [
+ ['job_id' => $childJobIDs[0], 'contact_id' => $cid1, 'email_id' => $eid1],
+ ['job_id' => $childJobIDs[0], 'contact_id' => $cid2, 'email_id' => $eid2],
+ ['job_id' => $childJobIDs[1], 'contact_id' => $cid1, 'email_id' => $eid1],
+ ['job_id' => $childJobIDs[1], 'contact_id' => $cid2, 'email_id' => $eid2],
+ ],
+ ])->column('id');
+
+ $onceEachQueue =
+ [
+ 'records' => [
+ ['event_queue_id' => $queueIDs[0]],
+ ['event_queue_id' => $queueIDs[1]],
+ ],
+ ];
+
+ $twiceOneQueue =
+ [
+ 'records' => [
+ ['event_queue_id' => $queueIDs[0]],
+ ['event_queue_id' => $queueIDs[0]],
+ ],
+ ];
+
+ $this->saveTestRecords('MailingEventDelivered', $onceEachQueue);
+ $this->createTestRecord('MailingEventBounce', ['event_queue_id' => $queueIDs[1]]);
+ $this->saveTestRecords('MailingEventOpened', $twiceOneQueue);
+ $trackableURLIDs = $this->saveTestRecords('MailingTrackableURL',
+ [
+ 'records' => [
+ ['mailing_id' => $mid1],
+ ['mailing_id' => $mid1],
+ ],
+ ])->column('id');
+
+ $this->saveTestRecords('MailingEventTrackableURLOpen',
+ [
+ 'records' => [
+ ['event_queue_id' => $queueIDs[0], 'trackable_url_id' => $trackableURLIDs[0]],
+ ['event_queue_id' => $queueIDs[0], 'trackable_url_id' => $trackableURLIDs[1]],
+ ['event_queue_id' => $queueIDs[0], 'trackable_url_id' => $trackableURLIDs[1]],
+ ['event_queue_id' => $queueIDs[1], 'trackable_url_id' => $trackableURLIDs[1]],
+ ],
+ ]);
+
+ $this->saveTestRecords('MailingEventForward', $twiceOneQueue);
+ $this->saveTestRecords('MailingEventReply', $twiceOneQueue);
+ $this->saveTestRecords('MailingEventUnsubscribe',
+ [
+ 'records' => [
+ ['event_queue_id' => $queueIDs[0], 'org_unsubscribe' => 0],
+ ['event_queue_id' => $queueIDs[0], 'org_unsubscribe' => 0],
+ ['event_queue_id' => $queueIDs[0], 'org_unsubscribe' => 1],
+ ['event_queue_id' => $queueIDs[0], 'org_unsubscribe' => 1],
+ ['event_queue_id' => $queueIDs[1], 'org_unsubscribe' => 1],
+ ],
+ ]);
+
+ $mailings = \Civi\Api4\Mailing::get(FALSE)
+ ->addSelect('stats_intended_recipients', 'stats_successful', 'stats_opens_total', 'stats_opens_unique', 'stats_clicks_total',
+ 'stats_clicks_unique', 'stats_bounces', 'stats_unsubscribes', 'stats_optouts', 'stats_forwards', 'stats_replies')
+ ->addWhere('id', 'IN', [$mid1, $mid2])
+ ->addOrderBy('id', 'ASC')
+ ->execute();
+
+ $this->assertEquals(2, $mailings[0]['stats_intended_recipients']);
+ $this->assertEquals(2, $mailings[1]['stats_intended_recipients']);
+ $this->assertEquals(1, $mailings[0]['stats_bounces']);
+ $this->assertEquals(0, $mailings[1]['stats_bounces']);
+ $this->assertEquals(1, $mailings[0]['stats_successful']);
+ $this->assertEquals(0, $mailings[1]['stats_successful']);
+ $this->assertEquals(2, $mailings[0]['stats_opens_total']);
+ $this->assertEquals(0, $mailings[1]['stats_opens_total']);
+ $this->assertEquals(1, $mailings[0]['stats_opens_unique']);
+ $this->assertEquals(0, $mailings[1]['stats_opens_unique']);
+ $this->assertEquals(4, $mailings[0]['stats_clicks_total']);
+ $this->assertEquals(0, $mailings[1]['stats_clicks_total']);
+ $this->assertEquals(3, $mailings[0]['stats_clicks_unique']);
+ $this->assertEquals(0, $mailings[1]['stats_clicks_unique']);
+ $this->assertEquals(2, $mailings[0]['stats_forwards']);
+ $this->assertEquals(0, $mailings[1]['stats_forwards']);
+ $this->assertEquals(2, $mailings[0]['stats_replies']);
+ $this->assertEquals(0, $mailings[1]['stats_replies']);
+ $this->assertEquals(1, $mailings[0]['stats_unsubscribes']);
+ $this->assertEquals(0, $mailings[1]['stats_unsubscribes']);
+ $this->assertEquals(2, $mailings[0]['stats_optouts']);
+ $this->assertEquals(0, $mailings[1]['stats_optouts']);
+ }
+
+}