updateData = []; if ($entity) { $this->load($entity); } $this->mailingListManager = MailingListManager::getInstance(); } /** * Set related entity directly or load it from the database by passing an ID. * * @param array|int $entity * * @return void * @throws \Civi\Mailinglistsync\Exceptions\MailinglistException */ protected function load(array|int $entity): void { if (is_int($entity)) { $id = $entity; try { $entity = static::RELATED_CLASS::get() ->addSelect('*') ->addSelect('custom.*') ->addWhere('id', '=', $id) ->execute() ->first(); if (!empty($entity)) { $this->setEntity($entity); } } catch (UnauthorizedException) { $type = static::RELATED_TYPE; throw new MailinglistException( "Could not get $type with id '$id' via API4 due to insufficient permissions", MailinglistException::ERROR_CODE_PERMISSION_DENIED ); } catch (\Exception) { $type = static::RELATED_TYPE; throw new MailinglistException( "Could not get $type with id '$id' via API4", $type === self::GROUP_MAILING_LIST ? MailinglistException::ERROR_CODE_GET_GROUP_MAILING_LISTS_FAILED : MailinglistException::ERROR_CODE_GET_EVENT_MAILING_LISTS_FAILED ); } } $this->setEntity($entity); } /** * Get related custom fields. * * @throws UnauthorizedException * @throws \CRM_Core_Exception */ static protected function getCustomFields(): array { return CustomField::get(FALSE) ->addSelect('*') ->addWhere('custom_group_id:name', '=', static::CUSTOM_GROUP_NAME) ->execute() ->getArrayCopy(); } /** * Maps field names from forms (e.g. custom_12_287) to custom field names. * * @param $fields * @return array * @throws UnauthorizedException * @throws \CRM_Core_Exception */ static public function translateCustomFields($fields): array { $customFields = self::getCustomFields(); $mapping = []; foreach ($customFields as $customField) { $fieldId = $customField['id']; $fieldName = "custom_$fieldId"; $getCustomField = function () use ($fields, $fieldName) { foreach ($fields as $key => $value) { if (preg_match("/^$fieldName" . "_.*/", $key)) { return ['field_name' => $key, 'value' => $value]; } } return NULL; }; $mapping[$customField['name']] = $getCustomField(); } return $mapping; } /** * Validate e-mail address. * * @param string $emailAddress * * @return bool */ static public function validateEmailAddress(string $emailAddress): bool { return (bool) filter_var($emailAddress, FILTER_VALIDATE_EMAIL); } /** * Checks if the domain of the e-mail address is the same as the configured * domain. * * @param string $emailAddress * * @return bool */ static public function validateEmailDomain(string $emailAddress): bool { $domain = explode('@', $emailAddress)[1]; return MailingListSettings::get('domain') === $domain; } /** * Validate custom fields. * * @param array $fields * @param \CRM_Core_Form $form * @param array $errors * * @return bool * @throws \CRM_Core_Exception * @throws \Civi\API\Exception\UnauthorizedException * @throws \Exception */ static public function validateCustomFields(array $fields, \CRM_Core_Form $form, array &$errors): bool { $result = TRUE; $customFields = self::getCustomFields(); $customValues = []; // Determine the entity type if ($form instanceof \CRM_Group_Form_Edit) { $entityId = $form->getEntityId(); $type = self::GROUP_MAILING_LIST; } elseif ($form instanceof \CRM_Event_Form_ManageEvent_EventInfo) { $entityId = $form->getEventID(); // It's things like this... $type = self::EVENT_MAILING_LIST; } else { throw new \Exception('Unknown form type'); } // Translate custom field names $customValues = self::translateCustomFields($fields); // Validate e-mail address if (!empty($customValues['E_mail_address']['value'])) { if (!self::validateEmailAddress($customValues['E_mail_address']['value'])) { $errors[$customValues['E_mail_address']['field_name']] = E::ts('Invalid e-mail address'); $result = FALSE; } if (!self::validateEmailDomain($customValues['E_mail_address']['value'])) { $errors[$customValues['E_mail_address']['field_name']] = E::ts( 'E-mail address does not match the configured domain'); $result = FALSE; } } // E-mail address must be unique for groups and events if (!empty($customValues['E_mail_address']['value'])) { $emailAddress = $customValues['E_mail_address']['value']; $groupId = self::GROUP_MAILING_LIST === $type ? $entityId : NULL; $eventId = self::EVENT_MAILING_LIST === $type ? $entityId : NULL; // Check if the e-mail address is already in use for a group $existingGroup = GroupMailingList::getByEmailAddress($emailAddress, $groupId); if ($existingGroup) { $errors[$customValues['E_mail_address']['field_name']] = E::ts("E-mail address '%1' already in use for group '%2'", [ 1 => $customValues['E_mail_address']['value'], 2 => $existingGroup->getTitle(), ]); $result = FALSE; } // Check if the e-mail address is already in use for an event $existingEvent = EventMailingList::getByEmailAddress($emailAddress, $eventId); if ($existingEvent) { $errors[$customValues['E_mail_address']['field_name']] = E::ts("E-mail address '%1' already in use for event '%2'", [ 1 => $customValues['E_mail_address']['value'], 2 => $existingEvent->getTitle(), ]); $result = FALSE; } } return $result; } /** * Get mailing lists by its e-mail address. * * @param string $emailAddress * @param int|null $excludeId ID to exclude from the search * * @return ?self * @throws \CRM_Core_Exception * @throws \Civi\API\Exception\UnauthorizedException */ static public function getByEmailAddress(string $emailAddress, int $excludeId = NULL): ?self { $result = static::RELATED_CLASS::get() ->addSelect('*') ->addSelect('custom.*') ->addWhere(static::CUSTOM_GROUP_NAME . '.E_mail_address', '=', $emailAddress); // If $excludeId is set, exclude it from the search if ($excludeId) { $result->addWhere('id', '!=', $excludeId); } $mailingListData = $result->execute()->first(); return !empty($mailingListData) ? new static($mailingListData) : NULL; } /** * Returns the related entity. * * @return array */ protected abstract function getEntity(): array; /** * Set the related entity. * * @param array $value * * @return void */ protected abstract function setEntity(array $value): void; /** * Check if this mailing list is enabled * * @return bool */ public function isEnabled(): bool { if (empty($this->getEntity())) { return FALSE; } return (bool)$this->getEntity()[static::CUSTOM_GROUP_NAME . '.Enable_mailing_list']; } /** * Check if this is a group. * * @return bool */ public function isGroup(): bool { return $this::RELATED_CLASS === \Civi\Api4\Group::class; } /** * Check if this is an event. * * @return bool */ public function isEvent(): bool { return $this::RELATED_CLASS === \Civi\Api4\Event::class; } /** * Check if this is an AD mailing list. * * @return bool */ public function isADGroup(): bool { return FALSE; } /** * Mark changes to be saved via the `save()` method. * * @param array $values */ protected function update(array $values): void { $this->updateData += $values; } /** * Saves the changes marked using the `update()` method. * * @return void * @throws \Civi\Mailinglistsync\Exceptions\MailinglistException */ public function save(): void { if (empty($this->updateData)) { return; } try { static::RELATED_CLASS::update() ->setValues($this->updateData) ->addWhere('id', '=', $this->getEntity()['id']) ->execute(); } catch (\Exception $e) { $type = static::RELATED_TYPE; throw new MailinglistException( "Could not update $type with id '{$this->getEntity()['id']}'\n$e", MailinglistException::ERROR_CODE_UPDATE_ENTITY_FAILED ); } } /** * Get the e-mail address of the mailing list. * * @return string */ public function getEmailAddress(): string { return $this->getEntity()[static::CUSTOM_GROUP_NAME . '.E_mail_address']; } /** * Get the title of the mailing list. * * @return string */ public function getTitle(): string { return $this->getEntity()['title']; } /** * Update the e-mail address of the mailing list. * * @param string $emailAddress * * @return void * @throws \Civi\Mailinglistsync\Exceptions\MailinglistException */ public function updateEmailAddress(string $emailAddress): void { // Validate email address if (!ADGroupMailingList::validateEmailAddress($emailAddress)) { throw new MailinglistException( "Invalid e-mail address '$emailAddress'", MailinglistException::ERROR_CODE_INVALID_EMAIL_ADDRESS ); } if (!ADGroupMailingList::validateEmailDomain($emailAddress)) { throw new MailinglistException( "E-mail address '$emailAddress' does not match the configured domain", MailinglistException::ERROR_CODE_EMAIL_DOMAIN_MISMATCH ); } static::update([ static::CUSTOM_GROUP_NAME . '.E_mail_address' => $emailAddress, ]); } /** * Get the location type name for the mailing list. * * Note: Use the getLocationTypes() from Utils which retrieves an array of * location type IDs and names from cache or database. * * @return string */ public static function getLocationTypeName(): string { return static::LOCATION_TYPE; } /** * Get a list of recipients. * * @return array E-mail addresses ['john.doe@example.org', ...] */ public abstract function getRecipients(): array; /** * Returns this mailing lists id. * * @return int */ public function getId(): int { return (int)$this->getEntity()['id']; } /** * Try to find an existing contact by the given parameters. * First tries to find the contact by the SID, then by the e-mail address * and finally by the first and last name (if restricting tags are set). * * @param string|null $firstName * @param string|null $lastName * @param string|null $email * @param string|null $sid * @param array|null $searchBy ['sid', 'email', 'names'] * * @return array|null ['contact' => $contact, 'found_by' => $found_by] * @throws \Civi\Mailinglistsync\Exceptions\MailinglistException */ protected static function findExistingContact( string $firstName = NULL, string $lastName = NULL, string $email = NULL, string $sid = NULL, array $searchBy = NULL ): ?array { // Set default search parameters. if (empty($searchBy)) { $searchBy = ['sid', 'email', 'names']; } // Get the tags to restrict the search to. $tags = \Civi::settings()->get(E::SHORT_NAME . '_ad_contact_tags'); // Prepare the get call for reuse. $prepareGetEmail = function () use ($tags) { $selectFields = [ 'id', 'first_name', 'last_name', 'email.email', 'email.location_type_id.name', 'Active_Directory.SID', ]; $call = \Civi\Api4\Contact::get() ->addSelect(...$selectFields) ->addJoin('Email AS email', 'LEFT', ['id', '=', 'email.contact_id']) ->addGroupBy('id'); if ($tags) { $call->addJoin('EntityTag AS entity_tag', 'INNER', ['id', '=', 'entity_tag.entity_id'], ['entity_tag.entity_table', '=', '"civicrm_contact"'], ['entity_tag.tag_id', 'IN', $tags] ); } return $call; }; // Try to find the contact by the SID. if (in_array('sid', $searchBy)) { try { $contact = $prepareGetEmail() ->addWhere('Active_Directory.SID', '=', $sid) ->execute() ->getArrayCopy(); } catch (\Exception $e) { throw new MailinglistException( "Could not get contact by SID '$sid': $e", MailinglistException::ERROR_CODE_GET_CONTACT_FAILED, ); } if (count($contact) > 1) { throw new MailinglistException( "Multiple contacts with the same SID '$sid' found", MailinglistException::ERROR_CODE_MULTIPLE_CONTACTS_FOUND ); } elseif (count($contact) === 1) { return ['contact' => array_pop($contact), 'found_by' => 'sid']; } } // Try fo find the contact by the e-mail address. if (in_array('email', $searchBy)) { try { $contact = $prepareGetEmail() ->addWhere('email.email', '=', $email) ->execute() ->getArrayCopy(); } catch (\Exception $e) { throw new MailinglistException( "Could not get contact by e-mail address '$email': $e", MailinglistException::ERROR_CODE_GET_CONTACT_FAILED, ); } if (count($contact) > 1) { throw new MailinglistException( "Multiple contacts with the same e-mail address '$email' found", MailinglistException::ERROR_CODE_MULTIPLE_CONTACTS_FOUND ); } elseif (count($contact) === 1) { return ['contact' => array_pop($contact), 'found_by' => 'email']; } } // Try to find the contact by the first and last name and only if the tags are set. if (in_array('names', $searchBy) && $tags) { try { $contact = $prepareGetEmail() ->addWhere('first_name', '=', $firstName) ->addWhere('last_name', '=', $lastName) ->execute() ->getArrayCopy(); } catch (\Exception $e) { throw new MailinglistException( "Could not get contact by first and last name '$firstName $lastName': $e", MailinglistException::ERROR_CODE_GET_CONTACT_FAILED, ); } if (count($contact) > 1) { throw new MailinglistException( "Multiple contacts with the same first and last name found", MailinglistException::ERROR_CODE_MULTIPLE_CONTACTS_FOUND ); } elseif (count($contact) === 1) { return ['contact' => array_pop($contact), 'found_by' => 'names']; } } // If no contact was found, return NULL. return NULL; } /** * Is this mailing list externally available? * * @return bool */ function isExternallyAvailable(): bool { return (bool)$this->getEntity()[static::CUSTOM_GROUP_NAME . '.Can_be_reached_externally']; } /** * Get the subject prefix. * * @return ?string */ function getSubjectPrefix(): ?string { return $this->getEntity()[static::CUSTOM_GROUP_NAME . '.Subject_prefix']; } /** * Synchronize this mailing list with mlmmj. * * @return array * */ function sync(): array { $result = []; $updateValues = []; $mlmmjApi = MailingListApi::getInstance(); try { // Get mailinglist $mailinglist = $mlmmjApi->getMailingList($this->getEmailAddress()); // Create mailinglist if it does not exist yet if (empty($mailinglist)) { $mlmmjApi->createMailingList($this->getEmailAddress(), [ 'close_list' => 'yes', 'only_subscriber_can_post' => $this->isExternallyAvailable() ? 'no' : 'yes', 'subject_prefix' => $this->getSubjectPrefix() ?? '', ]); $result['created'] = TRUE; } // If the mailing list exists, check for updates else { $data = $mailinglist['_data']; $externallyAvailable = $data['only_subscriber_can_post'] === 'no'; $subjectPrefix = $data['subject_prefix']; // Identify changes if ($externallyAvailable !== $this->isExternallyAvailable()) { $updateValues['only_subscriber_can_post'] = $this->isExternallyAvailable() ? 'no' : 'yes'; } if ($subjectPrefix !== ($this->getSubjectPrefix() ?? '')) { $updateValues['subject_prefix'] = $this->getSubjectPrefix() ?? ''; } // Update the mlmmj mailing list if (!empty($updateValues)) { $mlmmjApi->updateMailingList( $this->getEmailAddress(), $updateValues, ); $result['updated'] = $updateValues; } } // Compare recipients $civicrmRecipients = $this->getRecipients(); $civicrmRecipientsEmails = array_map(function ($recipient) { return $recipient->getEmail(); }, $civicrmRecipients); $mlmmjRecipients = $mlmmjApi->getMailingListSubscribers($this->getEmailAddress()); // Add and remove recipients $recipientsToAdd = array_diff($civicrmRecipientsEmails, $mlmmjRecipients); $recipientsToRemove = array_diff($mlmmjRecipients, $civicrmRecipientsEmails); // Add recipients if (!empty($recipientsToAdd) || !empty($recipientsToRemove)) { $mlmmjApi->updateSubscribers( $this->getEmailAddress(), $recipientsToAdd, $recipientsToRemove, ); $result['recipients'] = [ 'added' => array_values($recipientsToAdd), 'removed' => array_values($recipientsToRemove), ]; } // Create contact activities $added = array_filter($civicrmRecipients, function ($recipient) use ($recipientsToAdd) { return in_array($recipient->getEmail(), $recipientsToAdd); }); $removed = array_filter($civicrmRecipients, function ($recipient) use ($recipientsToRemove) { return in_array($recipient->getEmail(), $recipientsToRemove); }); // Create activities for added contacts foreach ($added as $recipient) { /* @var MailingListRecipient $recipient */ try { MailingListRecipient::createSyncActivity( $recipient->getContactId(), 'Completed', E::ts('Contact added to mailing list'), E::ts("This contact has been added to the mailing list '%1'.", [1 => $this->getEmailAddress()]) ); } catch (MailinglistException $e) { \Civi::log(E::LONG_NAME)->error($e->getLogMessage()); } } // Create activities for removed contacts foreach ($removed as $recipient) { /* @var MailingListRecipient $recipient */ try { MailingListRecipient::createSyncActivity( $recipient->getContactId(), 'Completed', E::ts('Contact removed from mailing list'), E::ts("This contact has been removed from the mailing list '%1'.", [1 => $this->getEmailAddress()]) ); } catch (MailinglistException $e) { \Civi::log(E::LONG_NAME)->error($e->getLogMessage()); } } } catch (\Exception $e) { \Civi::log(E::LONG_NAME)->error($e); $result['is_error'] = 1; $result['error'] = $e->getMessage(); } return $result; } /** * Delete the corresponding mailing list on mlmmj and the email address. * * @return void * @throws \Civi\Mailinglistsync\Exceptions\MailinglistException */ function delete(): void { $mlmmjApi = MailingListApi::getInstance(); $mlmmjApi->deleteMailingList($this->getEmailAddress()); } }