campaign unsubscribe change

This commit is contained in:
Yuri Kuznetsov
2024-06-29 10:14:17 +03:00
parent 0d2d708ba8
commit ece8405b33
10 changed files with 356 additions and 385 deletions
@@ -1,108 +0,0 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Modules\Crm\EntryPoints;
use Espo\Core\Utils\Client\ActionRenderer;
use Espo\Modules\Crm\Tools\MassEmail\UnsubscribeService;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\EntryPoint\Traits\NoAuth;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Utils\Metadata;
/**
* @noinspection PhpUnused
*/
class SubscribeAgain implements EntryPoint
{
use NoAuth;
public function __construct(
private Metadata $metadata,
private ActionRenderer $actionRenderer,
private UnsubscribeService $unsubscribeService,
) {}
/**
* @throws BadRequest
* @throws NotFound
*/
public function run(Request $request, Response $response): void
{
$id = $request->getQueryParam('id') ?? null;
$emailAddress = $request->getQueryParam('emailAddress') ?? null;
$hash = $request->getQueryParam('hash') ?? null;
if ($emailAddress && $hash) {
$this->processWithHash($response, $emailAddress, $hash);
return;
}
if (!$id || !is_string($id)) {
throw new BadRequest("No id.");
}
$this->unsubscribeService->subscribeAgain($id);
$this->display($response, ['queueItemId' => $id]);
}
/**
* @param array<string, mixed> $actionData
*/
private function display(Response $response, array $actionData): void
{
$data = [
'actionData' => $actionData,
'view' => $this->metadata->get(['clientDefs', 'Campaign', 'subscribeView']),
'template' => $this->metadata->get(['clientDefs', 'Campaign', 'subscribeTemplate']),
];
$params = ActionRenderer\Params::create('crm:controllers/unsubscribe', 'subscribeAgain', $data);
$this->actionRenderer->write($response, $params);
}
/**
* @throws NotFound
*/
private function processWithHash(Response $response, string $emailAddress, string $hash): void
{
$this->unsubscribeService->subscribeAgainWithHash($emailAddress, $hash);
$this->display($response, [
'emailAddress' => $emailAddress,
'hash' => $hash,
]);
}
}
@@ -72,9 +72,10 @@ class Unsubscribe implements EntryPoint
throw new BadRequest("No id.");
}
$this->unsubscribeService->unsubscribe($id);
$this->display($response, ['queueItemId' => $id]);
$this->display($response, [
'queueItemId' => $id,
'isSubscribed' => $this->unsubscribeService->isSubscribed($id),
]);
}
/**
@@ -98,11 +99,10 @@ class Unsubscribe implements EntryPoint
*/
private function processWithHash(Response $response, string $emailAddress, string $hash): void
{
$this->unsubscribeService->unsubscribeWithHash($emailAddress, $hash);
$this->display($response,[
$this->display($response, [
'emailAddress' => $emailAddress,
'hash' => $hash,
'isSubscribed' => $this->unsubscribeService->isSubscribedWithHash($emailAddress, $hash),
]);
}
}
@@ -65,5 +65,23 @@
"method": "post",
"actionClassName": "Espo\\Modules\\Crm\\Tools\\MassEmail\\Api\\PostUnsubscribe",
"noAuth": true
},
{
"route": "/Campaign/unsubscribe/:id",
"method": "delete",
"actionClassName": "Espo\\Modules\\Crm\\Tools\\MassEmail\\Api\\DeleteUnsubscribe",
"noAuth": true
},
{
"route": "/Campaign/unsubscribe/:emailAddress/:hash",
"method": "post",
"actionClassName": "Espo\\Modules\\Crm\\Tools\\MassEmail\\Api\\PostUnsubscribe",
"noAuth": true
},
{
"route": "/Campaign/unsubscribe/:emailAddress/:hash",
"method": "delete",
"actionClassName": "Espo\\Modules\\Crm\\Tools\\MassEmail\\Api\\DeleteUnsubscribe",
"noAuth": true
}
]
@@ -1,3 +1,4 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
@@ -26,27 +27,38 @@
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
define('crm:views/campaign/subscribe-again', ['view'], function (Dep) {
namespace Espo\Modules\Crm\Tools\MassEmail\Api;
return Dep.extend({
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Modules\Crm\Tools\MassEmail\UnsubscribeService;
template: 'crm:campaign/subscribe-again',
/** @noinspection PhpUnused */
class DeleteUnsubscribe implements Action
{
public function __construct(private UnsubscribeService $service) {}
data: function () {
var revertUrl;
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$hash = $request->getRouteParam('hash');
$emailAddress = $request->getRouteParam('emailAddress');
var actionData = this.options.actionData;
if ($hash && $emailAddress) {
$this->service->subscribeAgainWithHash($emailAddress, $hash);
if (actionData.hash && actionData.emailAddress) {
revertUrl = '?entryPoint=unsubscribe&emailAddress=' + actionData.emailAddress +
'&hash=' + actionData.hash;
} else {
revertUrl = '?entryPoint=unsubscribe&id=' + actionData.queueItemId;
}
return ResponseComposer::json(true);
}
return {
revertUrl: revertUrl,
};
},
});
});
if (!$id) {
throw new BadRequest();
}
$this->service->subscribeAgain($id);
return ResponseComposer::json(true);
}
}
@@ -44,6 +44,14 @@ class PostUnsubscribe implements Action
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$hash = $request->getRouteParam('hash');
$emailAddress = $request->getRouteParam('emailAddress');
if ($hash && $emailAddress) {
$this->service->unsubscribeWithHash($emailAddress, $hash);
return ResponseComposer::json(true);
}
if (!$id) {
throw new BadRequest();
@@ -42,6 +42,7 @@ use Espo\Modules\Crm\Entities\TargetList;
use Espo\Modules\Crm\Tools\Campaign\LogService;
use Espo\Modules\Crm\Tools\MassEmail\Util as MassEmailUtil;
use Espo\ORM\Collection;
use Espo\ORM\Entity;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
class UnsubscribeService
@@ -58,6 +59,234 @@ class UnsubscribeService
* @throws NotFound
*/
public function unsubscribe(string $queueItemId): void
{
[$queueItem, $campaign, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($address) {
$address->setOptedOut(true);
$this->entityManager->saveEntity($address);
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
/** @var Collection<TargetList> $targetListList */
$targetListList = $this->entityManager
->getRDBRepository(MassEmail::ENTITY_TYPE)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListList as $targetList) {
$relation = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link);
if ($relation->getColumn($target, 'optedOut')) {
continue;
}
$relation->updateColumnsById($target->getId(), ['optedOut' => true]);
$hookData = [
'link' => $link,
'targetId' => $target->getId(),
'targetType' => $target->getEntityType(),
];
$this->hookManager->process(
TargetList::ENTITY_TYPE,
'afterOptOut',
$targetList,
[],
$hookData
);
}
$this->hookManager->process($target->getEntityType(), 'afterOptOut', $target);
if ($campaign) {
$this->service->logOptedOut($campaign->getId(), $queueItem, $target);
}
}
/**
* @throws NotFound
*/
public function subscribeAgain(string $queueItemId): void
{
[, $campaign, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$ea = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($ea) {
$ea->setOptedOut(false);
$this->entityManager->saveEntity($ea);
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
/** @var Collection<TargetList> $targetListList */
$targetListList = $this->entityManager
->getRDBRepository(MassEmail::ENTITY_TYPE)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListList as $targetList) {
$relation = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link);
if (!$relation->getColumn($target, 'optedOut')) {
continue;
}
$relation->updateColumnsById($target->getId(), ['optedOut' => false]);
$hookData = [
'link' => $link,
'targetId' => $target->getId(),
'targetType' => $target->getEntityType(),
];
$this->hookManager
->process(TargetList::ENTITY_TYPE, 'afterCancelOptOut', $targetList, [], $hookData);
}
$this->hookManager->process($target->getEntityType(), 'afterCancelOptOut', $target);
if ($campaign) {
$logRecord = $this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItemId,
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
])
->order('createdAt', true)
->findOne();
if ($logRecord) {
$this->entityManager->removeEntity($logRecord);
}
}
}
/**
* @throws NotFound
*/
public function unsubscribeWithHash(string $emailAddress, string $hash): void
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
if ($address->isOptedOut()) {
return;
}
$address->setOptedOut(true);
$this->entityManager->saveEntity($address);
$entityList = $this->getEmailAddressRepository()->getEntityListByAddressId($address->getId());
foreach ($entityList as $entity) {
$this->hookManager->process($entity->getEntityType(), 'afterOptOut', $entity);
}
}
/**
* @throws NotFound
*/
public function subscribeAgainWithHash(string $emailAddress, string $hash): void
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
if (!$address->isOptedOut()) {
return;
}
$entityList = $this->getEmailAddressRepository()->getEntityListByAddressId($address->getId());
$address->setOptedOut(false);
$this->entityManager->saveEntity($address);
foreach ($entityList as $entity) {
$this->hookManager->process($entity->getEntityType(), 'afterCancelOptOut', $entity);
}
}
/**
* @throws NotFound
*/
public function isSubscribed(string $queueItemId): bool
{
[,, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($address && !$address->isOptedOut()) {
return true;
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
/** @var Collection<TargetList> $targetListList */
$targetListList = $this->entityManager
->getRDBRepository(MassEmail::ENTITY_TYPE)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListList as $targetList) {
$relation = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link);
if (!$relation->getColumn($target, 'optedOut')) {
return true;
}
}
return false;
}
/**
* @throws NotFound
*/
public function isSubscribedWithHash(string $emailAddress, string $hash): bool
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
return !$address->isOptedOut();
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
/**
* @return array{EmailQueueItem, ?Campaign, MassEmail, Entity}
* @throws NotFound
*/
private function getRecords(string $queueItemId): array
{
/** @var ?EmailQueueItem $queueItem */
$queueItem = $this->entityManager->getEntityById(EmailQueueItem::ENTITY_TYPE, $queueItemId);
@@ -94,165 +323,13 @@ class UnsubscribeService
throw new NotFound();
}
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$ea = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($ea) {
$ea->setOptedOut(true);
$this->entityManager->saveEntity($ea);
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
/** @var Collection<TargetList> $targetListList */
$targetListList = $this->entityManager
->getRDBRepository(MassEmail::ENTITY_TYPE)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListList as $targetList) {
$relation = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link);
if ($relation->getColumn($target, 'optedOut')) {
continue;
}
$relation->updateColumnsById($target->getId(), ['optedOut' => true]);
$hookData = [
'link' => $link,
'targetId' => $targetId,
'targetType' => $targetType,
];
$this->hookManager->process(
TargetList::ENTITY_TYPE,
'afterOptOut',
$targetList,
[],
$hookData
);
}
$this->hookManager->process($target->getEntityType(), 'afterOptOut', $target);
if ($campaign) {
$this->service->logOptedOut($campaign->getId(), $queueItem, $target);
}
return [$queueItem, $campaign, $massEmail, $target];
}
/**
* @throws NotFound
*/
public function subscribeAgain(string $queueItemId): void
{
/** @var ?EmailQueueItem $queueItem */
$queueItem = $this->entityManager->getEntity(EmailQueueItem::ENTITY_TYPE, $queueItemId);
if (!$queueItem) {
throw new NotFound("No item.");
}
$campaign = null;
$massEmailId = $queueItem->getMassEmailId();
if (!$massEmailId) {
throw new NotFound("No Mass Email ID.");
}
/** @var ?MassEmail $massEmail */
$massEmail = $this->entityManager->getEntityById(MassEmail::ENTITY_TYPE, $massEmailId);
if (!$massEmail) {
throw new NotFound("Mass Email not found.");
}
if ($massEmail->getCampaignId()) {
/** @var ?Campaign $campaign */
$campaign = $this->entityManager->getEntityById(Campaign::ENTITY_TYPE, $massEmail->getCampaignId());
}
$targetType = $queueItem->getTargetType();
$targetId = $queueItem->getTargetId();
$target = $this->entityManager->getEntityById($targetType, $targetId);
if (!$target) {
throw new NotFound("Record not found.");
}
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$ea = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($ea) {
$ea->setOptedOut(false);
$this->entityManager->saveEntity($ea);
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
/** @var Collection<TargetList> $targetListList */
$targetListList = $this->entityManager
->getRDBRepository(MassEmail::ENTITY_TYPE)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListList as $targetList) {
$relation = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link);
if (!$relation->getColumn($target, 'optedOut')) {
continue;
}
$relation->updateColumnsById($target->getId(), ['optedOut' => false]);
$hookData = [
'link' => $link,
'targetId' => $targetId,
'targetType' => $targetType,
];
$this->hookManager
->process(TargetList::ENTITY_TYPE, 'afterCancelOptOut', $targetList, [], $hookData);
}
$this->hookManager->process($target->getEntityType(), 'afterCancelOptOut', $target);
if ($campaign) {
$logRecord = $this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItemId,
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
])
->order('createdAt', true)
->findOne();
if ($logRecord) {
$this->entityManager->removeEntity($logRecord);
}
}
}
/**
* @throws NotFound
*/
public function unsubscribeWithHash(string $emailAddress, string $hash): void
private function getEmailAddressWithHash(string $emailAddress, string $hash): EmailAddress
{
$hash2 = $this->hasher->hash($emailAddress);
@@ -260,64 +337,12 @@ class UnsubscribeService
throw new NotFound();
}
$repository = $this->getEmailAddressRepository();
$address = $repository->getByAddress($emailAddress);
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if (!$address) {
throw new NotFound();
}
$entityList = $repository->getEntityListByAddressId($address->getId());
if ($address->isOptedOut()) {
return;
}
$address->setOptedOut(true);
$this->entityManager->saveEntity($address);
foreach ($entityList as $entity) {
$this->hookManager->process($entity->getEntityType(), 'afterOptOut', $entity);
}
}
/**
* @throws NotFound
*/
public function subscribeAgainWithHash(string $emailAddress, string $hash): void
{
$hash2 = $this->hasher->hash($emailAddress);
if ($hash2 !== $hash) {
throw new NotFound();
}
$repository = $this->getEmailAddressRepository();
$address = $repository->getByAddress($emailAddress);
if (!$address) {
throw new NotFound();
}
$entityList = $repository->getEntityListByAddressId($address->getId());
if (!$address->isOptedOut()) {
return;
}
$address->setOptedOut(false);
$this->entityManager->saveEntity($address);
foreach ($entityList as $entity) {
$this->hookManager->process($entity->getEntityType(), 'afterCancelOptOut', $entity);
}
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
return $address;
}
}
@@ -1,14 +0,0 @@
<div class="container content">
<div class="block-center-md">
<div class="panel panel-default">
<div class="panel-body">
<p>
{{translate 'subscribedAgain' category='messages' scope='Campaign'}}
</p>
<p>
<a class="btn btn-default btn-sm" href="{{revertUrl}}">{{translate 'Unsubscribe again' scope='Campaign'}}</a>
</p>
</div>
</div>
</div>
</div>
@@ -3,10 +3,17 @@
<div class="panel panel-default">
<div class="panel-body">
<p>
{{translate 'unsubscribed' category='messages' scope='Campaign'}}
</p>
<p>
<a class="btn btn-default btn-sm" href="{{revertUrl}}">{{translate 'Subscribe again' scope='Campaign'}}</a>
{{#if isSubscribed}}
<a
class="btn btn-primary"
data-action="unsubscribe"
>{{translate 'Unsubscribe' scope='Campaign'}}</a>
{{else}}
<a
class="btn btn-default"
data-action="subscribe"
>{{translate 'Subscribe again' scope='Campaign'}}</a>
{{/if}}
</p>
</div>
</div>
@@ -41,18 +41,6 @@ class UnsubscribeController extends Controller {
view.render();
});
}
// noinspection JSUnusedGlobalSymbols
actionSubscribeAgain(data) {
const viewName = data.view || 'crm:views/campaign/subscribe-again';
this.entire(viewName, {
actionData: data.actionData,
template: data.template,
}, view => {
view.render();
});
}
}
export default UnsubscribeController;
@@ -26,24 +26,59 @@
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
define('crm:views/campaign/unsubscribe', ['view'], function (Dep) {
import View from 'view';
return Dep.extend({
class CampaignUnsubscribeView extends View {
template: 'crm:campaign/unsubscribe',
template = 'crm:campaign/unsubscribe'
data: function () {
var revertUrl;
data() {
return {
isSubscribed: this.isSubscribed,
};
}
var actionData = this.options.actionData;
setup() {
super.setup();
revertUrl = actionData.hash && actionData.emailAddress ?
'?entryPoint=subscribeAgain&emailAddress=' + actionData.emailAddress + '&hash=' + actionData.hash :
'?entryPoint=subscribeAgain&id=' + actionData.queueItemId;
this.actionData = /** @type {Record} */this.options.actionData;
return {
revertUrl: revertUrl,
};
},
});
});
this.isSubscribed = this.actionData.isSubscribed;
const endpointUrl = this.actionData.hash && this.actionData.emailAddress ?
`Campaign/unsubscribe/${this.actionData.emailAddress}/${this.actionData.hash}`:
`Campaign/unsubscribe/${this.actionData.queueItemId}`;
this.addActionHandler('subscribe', () => {
Espo.Ui.notify(' ... ');
Espo.Ajax.deleteRequest(endpointUrl)
.then(() => {
this.isSubscribed = true;
this.reRender().then(() => {
const message = this.translate('subscribedAgain', 'messages', 'Campaign');
Espo.Ui.notify(message, 'success', 0, {closeButton: true});
});
});
});
this.addActionHandler('unsubscribe', () => {
Espo.Ui.notify(' ... ');
Espo.Ajax.postRequest(endpointUrl)
.then(() => {
Espo.Ui.success(this.translate('unsubscribed', 'messages', 'Campaign'), {closeButton: true});
this.isSubscribed = false;
this.reRender().then(() => {
const message = this.translate('unsubscribed', 'messages', 'Campaign');
Espo.Ui.notify(message, 'success', 0, {closeButton: true});
});
});
});
}
}
export default CampaignUnsubscribeView;