api/src/MassCommunication/Admin/Service/CampaignManager.php
<?php
/**
* Copyright (c) 2021, MOBICOOP. All rights reserved.
* This project is dual licensed under AGPL and proprietary licence.
***************************
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <gnu.org/licenses>.
***************************
* Licence MOBICOOP described in the file
* LICENSE
*/
namespace App\MassCommunication\Admin\Service;
use App\Communication\Entity\Medium;
use App\Communication\Repository\MediumRepository;
use App\MassCommunication\CampaignProvider\SendinBlueProvider;
use App\MassCommunication\Entity\Campaign;
use App\MassCommunication\Entity\Delivery;
use App\MassCommunication\Entity\Recipient;
use App\MassCommunication\Entity\Sender;
use App\MassCommunication\Exception\CampaignException;
use App\MassCommunication\Ressource\MassCommunicationHook;
use App\User\Entity\User;
use App\User\Exception\UserNotFoundException;
use App\User\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/**
* Campaign manager service in administration context.
*/
class CampaignManager
{
public const MAIL_PROVIDER_SENDINBLUE = 'SendinBlue';
public const MODE_TEST = 1;
public const MODE_PROD = 2;
public const MODES = [
self::MODE_TEST,
self::MODE_PROD,
];
private $templating;
private $translator;
private $entityManager;
private $mediumRepository;
private $userRepository;
private $mailerProvider;
private $mailerDomain;
private $mailerIp;
private $mailerReplyTo;
private $mailerSenderEmail;
private $mailerSenderName;
private $massEmailProvider;
private $massEmailProviderIpRange1;
private $massEmailProviderIpRange2;
private $massSmsProvider;
private $logger;
/**
* Constructor.
*/
public function __construct(
Environment $templating,
TranslatorInterface $translator,
EntityManagerInterface $entityManager,
MediumRepository $mediumRepository,
UserRepository $userRepository,
LoggerInterface $logger,
string $mailTemplate,
string $mailerProvider,
array $mailerProviderIpRange1,
array $mailerProviderIpRange2,
string $mailerApiKey,
string $mailerClientName,
int $mailerClientId,
string $mailerClientTemplateId,
string $mailerReplyTo,
string $mailerSenderEmail,
string $mailerSenderName,
string $mailerDomain,
string $mailerIp,
string $smsProvider
) {
$this->templating = $templating;
$this->translator = $translator;
$this->entityManager = $entityManager;
$this->mediumRepository = $mediumRepository;
$this->userRepository = $userRepository;
$this->mailTemplate = $mailTemplate;
$this->mailerProvider = $mailerProvider;
$this->mailerClientName = $mailerClientName;
$this->mailerDomain = $mailerDomain;
$this->mailerReplyTo = $mailerReplyTo;
$this->mailerSenderEmail = $mailerSenderEmail;
$this->mailerSenderName = $mailerSenderName;
$this->mailerIp = $mailerIp;
$this->logger = $logger;
switch ($mailerProvider) {
case self::MAIL_PROVIDER_SENDINBLUE:
$this->massEmailProvider = new SendinBlueProvider($mailerApiKey, $mailerClientId, $mailerSenderName, $mailerSenderEmail, $mailerReplyTo, $mailerClientTemplateId);
break;
}
$this->massEmailProviderIpRange1 = $mailerProviderIpRange1;
$this->massEmailProviderIpRange2 = $mailerProviderIpRange2;
switch ($smsProvider) {
// none yet !
default: $this->massSmsProvider = null;
}
}
/**
* Add a campaign.
*
* @param Campaign $campaign The campaign to add
* @param User $user The user that adds the campaign
*
* @return Campaign The created campaign
*/
public function addCampaign(Campaign $campaign, User $user)
{
$campaign->setMedium($this->mediumRepository->find(Medium::MEDIUM_EMAIL));
$campaign->setUser($user);
$campaign->setEmail($this->mailerSenderEmail);
$campaign->setReplyTo($this->mailerReplyTo);
$campaign->setFromName($this->mailerSenderName);
$this->entityManager->persist($campaign);
$this->entityManager->flush();
return $campaign;
}
/**
* Patch a campaign.
*
* @param Campaign $campaign The campaign to update
* @param array $fields The updated fields
*
* @return Campaign The campaign updated
*/
public function patchCampaign(Campaign $campaign, array $fields)
{
// persist the campaign
$this->entityManager->persist($campaign);
$this->entityManager->flush();
// return the campaign
return $campaign;
}
/**
* Delete a campaign.
*
* @param Campaign $campaign The campaign to delete
*/
public function deleteCampaign(Campaign $campaign)
{
$this->entityManager->remove($campaign);
$this->entityManager->flush();
}
/**
* Associate users to a campaign (complete Campaign information, and create deliveries only if selection).
*
* @param Campaign $campaign The campaign
* @param iterable $users The users
* @param array $filters The filters if the filter type is 'filter'
*/
public function associateUsers(Campaign $campaign, iterable $users, array $filters = [])
{
switch ($campaign->getFilterType()) {
case Campaign::FILTER_TYPE_SELECTION:
// remove selection if it exists before adding the new one
$campaign->removeDeliveries();
$campaign->setFilters(null);
foreach ($users as $user) {
$delivery = new Delivery();
$delivery->setCampaign($campaign);
$delivery->setUser($user);
$delivery->setStatus(Delivery::STATUS_PENDING);
$this->entityManager->persist($delivery);
}
// force updated date
$campaign->setAutoUpdatedDate();
break;
case Campaign::FILTER_TYPE_FILTER:
// remove selection if it exists
$campaign->removeDeliveries();
$campaign->setFilters($this->stringFilters($filters));
$campaign->setDeliveryCount(iterator_count($users));
$this->entityManager->persist($campaign);
break;
}
$this->entityManager->flush();
}
/**
* Associate community users to a campaign (complete Campaign information, and create deliveries only if selection).
*
* @param Campaign $campaign The campaign
* @param iterable $members The members
* @param array $filters The filters if the filter type is 'filter'
*/
public function associateCommunityUsers(Campaign $campaign, iterable $members, array $filters = [])
{
switch ($campaign->getFilterType()) {
case Campaign::FILTER_TYPE_SELECTION:
// remove selection if it exists before adding the new one
$campaign->removeDeliveries();
$campaign->setFilters(null);
foreach ($members as $member) {
$delivery = new Delivery();
$delivery->setCampaign($campaign);
$delivery->setUser($member->getUser());
$delivery->setStatus(Delivery::STATUS_PENDING);
$this->entityManager->persist($delivery);
}
// force updated date
$campaign->setAutoUpdatedDate();
break;
case Campaign::FILTER_TYPE_FILTER:
// remove selection if it exists
$campaign->removeDeliveries();
$campaign->setFilters($this->stringFilters($filters));
$campaign->setDeliveryCount(iterator_count($members));
$this->entityManager->persist($campaign);
break;
}
$this->entityManager->flush();
}
/**
* Send the campaign to the associated users, or to the creator if it's a test.
*
* @param Campaign $campaign The campaign
* @param iterable $users The users
* @param int $mode The sending mode (test or prod)
*
* @return Campaign The campaign
*/
public function send(Campaign $campaign, iterable $users, int $mode)
{
// the delivery count may have changed
$campaign->setDeliveryCount(iterator_count($users));
$this->entityManager->persist($campaign);
$this->entityManager->flush();
switch ($campaign->getMedium()->getId()) {
case Medium::MEDIUM_EMAIL:
return $this->sendMassEmail($campaign, $users, $mode);
break;
case Medium::MEDIUM_SMS:
return $this->sendMassSms($campaign, $users, $mode);
break;
default:
break;
}
return $campaign;
}
/**
* Handle an unsubscribe webhook.
*
* @param Request $request The request that contains the data
*/
public function handleUnsubscribeHook(MassCommunicationHook $hook, Request $request): JsonResponse
{
switch ($this->mailerProvider) {
case self::MAIL_PROVIDER_SENDINBLUE:
// Sendinblue uses ip range
if (!in_array(ip2long($request->getClientIp()), range(ip2long($this->massEmailProviderIpRange1['minIp']), ip2long($this->massEmailProviderIpRange1['maxIp']))) || !in_array(ip2long($request->getClientIp()), range(ip2long($this->massEmailProviderIpRange2['minIp']), ip2long($this->massEmailProviderIpRange2['maxIp'])))) {
throw new \Exception('Unauthorized');
}
if (!$email = $hook->getEmail()) {
throw new \Exception('Missing email');
}
if (!$user = $this->userRepository->findOneBy(['email' => $email])) {
throw new UserNotFoundException('User not found');
}
// @var User $user
$user->setNewsSubscription(false);
$this->entityManager->persist($user);
$this->entityManager->flush();
break;
default:
break;
}
return new JsonResponse(['message' => 'OK'], 200);
}
/**
* Send messages for a campaign by email.
*
* @param Campaign $campaign The campaign to send the messages for
* @param iterable $users The users
* @param int $mode The sending mode (test or prod)
*
* @return Campaign the campaign modified with the result of the send
*/
private function sendMassEmail(Campaign $campaign, iterable $users, int $mode)
{
// first we construct the recipients array
$recipients = [];
switch ($campaign->getFilterType()) {
case Campaign::FILTER_TYPE_SELECTION:
foreach ($campaign->getDeliveries() as $delivery) {
// @var Delivery $delivery
$recipients[] = new Recipient($delivery->getUser()->getEmail(), $delivery->getUser()->getGivenName(), $delivery->getUser()->getFamilyName(), null, $delivery->getUser()->getUnsubscribeToken());
}
break;
case Campaign::FILTER_TYPE_FILTER:
/**
* @var User $user
*/
foreach ($users as $user) {
$recipients[] = new Recipient($user->getEmail(), $user->getGivenName(), $user->getFamilyName(), null, $user->getUnsubscribeToken());
}
break;
}
// then we send the message or test message
switch ($mode) {
case self::MODE_TEST:
// we set the sender
$sender = new Sender();
$sender->setUser($campaign->getUser());
// we check if we have already created a campaign provider
if (is_null($campaign->getProviderCampaignId())) {
// we create the campaign on provider side
try {
$providerCampaign = $this->massEmailProvider->createCampaign($campaign->getName(), $sender, $campaign->getSubject(), $this->getFormedEmailBody($campaign->getBody()), $recipients);
} catch (\Exception $e) {
throw new CampaignException($e->getMessage());
}
// we set the campaign provider id
$campaign->setProviderCampaignId($providerCampaign['id']);
}
// we send the test email with the creator of the campaign as recipient
$this->massEmailProvider->sendCampaignTest($campaign->getName(), $campaign->getProviderCampaignId(), [$campaign->getUser()->getEmail()]);
// update the campaign if needed
if (Campaign::STATUS_CREATED != $campaign->getStatus()) {
$campaign->setStatus(Campaign::STATUS_CREATED);
$this->entityManager->persist($campaign);
$this->entityManager->flush();
}
break;
case self::MODE_PROD:
$this->massEmailProvider->sendCampaign($campaign->getName(), $campaign->getProviderCampaignId());
$campaign->setStatus(Campaign::STATUS_SENT);
$this->entityManager->persist($campaign);
$this->entityManager->flush();
break;
}
return $campaign;
}
/**
* Send messages for a campaign by sms.
*
* @param Campaign $campaign The campaign to send the messages for
* @param iterable $users The users
* @param int $mode The sending mode (test or prod)
*
* @return Campaign the campaign modified with the result of the send
*/
private function sendMassSms(Campaign $campaign, iterable $users, int $mode)
{
// first we construct the recipients array
$recipients = [];
switch ($campaign->getFilterType()) {
case Campaign::FILTER_TYPE_SELECTION:
foreach ($campaign->getDeliveries() as $delivery) {
// @var Delivery $delivery
$recipients[] = new Recipient($delivery->getUser()->getEmail(), $delivery->getUser()->getGivenName(), $delivery->getUser()->getFamilyName(), $delivery->getUser()->getTelephone());
}
break;
case Campaign::FILTER_TYPE_FILTER:
/**
* @var User $user
*/
foreach ($users as $user) {
$recipients[] = new Recipient($user()->getEmail(), $user()->getGivenName(), $user()->getFamilyName(), $user->getTelephone());
}
break;
}
// then we send the message or test message
// TODO : finish !
switch ($mode) {
case self::MODE_TEST:
break;
case self::MODE_PROD:
break;
}
return $campaign;
}
/**
* Converts an array of filters to an url-friendly string of filters.
*
* @param array $filters The array of filters as key=>value
*
* @return string The filters as a string
*/
private function stringFilters(array $filters)
{
$stringFilters = '';
foreach ($filters as $filter => $value) {
// value may be an array itself
if (is_array($value)) {
foreach ($value as $key => $val) {
if (!is_int($key)) {
$stringFilters .= $filter.'['.$key.']='.$val.'&';
} else {
$stringFilters .= $filter.'='.$val.'&';
}
}
} else {
$stringFilters .= $filter.'='.$value.'&';
}
}
return substr($stringFilters, 0, -1);
}
/**
* Create a well-formed body for email send.
* Note : the context variables should be present in the template.
*
* @param string $body The initial body
*
* @return string The templated body
*/
private function getFormedEmailBody(?string $body): string
{
$encodedBody = json_decode($body, true);
$arrayForTemplate = [];
foreach ($encodedBody as $parts) {
if ('image' == $parts['type']) {
$arrayForTemplate[] = [
'type' => $parts['type'],
'content' => $parts['src'],
'position' => $parts['position'],
];
} else {
$arrayForTemplate[] = [
'type' => $parts['type'],
'content' => $parts['value'],
'position' => $parts['position'],
];
}
}
return $this->templating->render(
$this->mailTemplate,
['arrayForTemplate' => $arrayForTemplate]
);
}
}