<?php
namespace App\EventSubscriber;
use App\DBAL\Types\MessageAutomationChannelType;
use App\DBAL\Types\MessageAutomationEventType;
use App\DBAL\Types\MessageAutomationStatusType;
use App\Entity\Customer;
use App\Entity\Lead\Lead;
use App\Entity\Lead\Prospect;
use App\Entity\MessageAutomation;
use App\Entity\MessageAutomationLog;
use App\Event\AutomationEvent;
use App\Events;
use App\Service\CallFire;
use App\Service\MergeFields;
use App\Service\UnsubscribeToken;
use App\Util\AppUtil;
use Doctrine\Persistence\ManagerRegistry;
use EWZ\SymfonyAdminBundle\Event\ObjectEvent;
use EWZ\SymfonyAdminBundle\EventSubscriber\EmailSubscriber as BaseEmailSubscriber;
use Html2Text\Html2Text;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
use Twig\Environment as TwigEnvironment;
/**
* Emails events.
*/
class EmailSubscriber extends BaseEmailSubscriber
{
/** @var ManagerRegistry */
private $managerRegistry;
/** @var TranslatorInterface */
private $translator;
/** @var MergeFields */
private $mergeFields;
/** @var CallFire */
private $callFire;
/** @var UnsubscribeToken */
private $unsubscribeToken;
/** @var string */
private $automationsSender;
/**
* @param TwigEnvironment $twig
* @param MailerInterface $mailer
* @param UrlGeneratorInterface $urlGenerator
* @param ManagerRegistry $managerRegistry
* @param TranslatorInterface $translator
* @param MergeFields $mergeFields
* @param CallFire $callFire
* @param UnsubscribeToken $unsubscribeToken
* @param string $sender
* @param string|null $automationsSender
*/
public function __construct(
TwigEnvironment $twig,
MailerInterface $mailer,
UrlGeneratorInterface $urlGenerator,
ManagerRegistry $managerRegistry,
TranslatorInterface $translator,
MergeFields $mergeFields,
CallFire $callFire,
UnsubscribeToken $unsubscribeToken,
string $sender,
string $automationsSender = null
) {
parent::__construct($twig, $mailer, $urlGenerator, $sender);
$this->managerRegistry = $managerRegistry;
$this->translator = $translator;
$this->mergeFields = $mergeFields;
$this->callFire = $callFire;
$this->unsubscribeToken = $unsubscribeToken;
$this->automationsSender = $automationsSender;
// override
if ($this->isNullSender($this->automationsSender)) {
$this->automationsSender = $sender;
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array
{
return array_merge(
parent::getSubscribedEvents(),
[
Events::EMAIL_CLIENT_LOGIN_CREDENTIAL => 'onEmailClientLoginCredential',
Events::EMAIL_CUSTOMER_MESSAGE => 'onEmailCustomerMessage',
// message automations
Events::EMAIL_PROSPECT_CREATED => 'onEmailProspectCreated',
Events::EMAIL_LEAD_CREATED => 'onEmailLeadCreated',
Events::EMAIL_OPPORTUNITY_CREATED => 'onEmailOpportunityCreated',
Events::EMAIL_CUSTOMER_CREATED => 'onEmailCustomerCreated',
]
);
}
/**
* @param ObjectEvent $event
*/
public function onEmailClientLoginCredential(ObjectEvent $event): void
{
/** @var Customer\Customer $customer */
$customer = $event->getObject();
$url = $this->urlGenerator->generate('client_security_login', [], UrlGeneratorInterface::ABSOLUTE_URL);
$context = [
'url' => $url,
'customer' => $customer,
];
$this->sendMessage('client/resetting/email.txt.twig', $context, $customer->getEmail());
}
/**
* @param ObjectEvent $event
*/
public function onEmailCustomerMessage(ObjectEvent $event): void
{
/** @var Customer\Message $message */
$message = $event->getObject();
$context = [
'message' => $message,
];
foreach ($message->getContacts() as $contact) {
if ($message->isEmailContact($contact)) {
$this->sendMessage('admin/messages/email.txt.twig', $context, $contact);
}
if ($message->isPhoneContact($contact)) {
$html = new Html2Text($message->getBody());
try {
$this->callFire->sendText($html->getText(), $contact);
} catch (\Exception $e) {
}
}
}
}
/**
* @param AutomationEvent $event
*/
public function onEmailProspectCreated(AutomationEvent $event): void
{
$this->dispatchAutomationsForProspect($event, MessageAutomationEventType::PROSPECT_CREATED, true);
}
/**
* @param AutomationEvent $event
*/
public function onEmailLeadCreated(AutomationEvent $event): void
{
$this->dispatchAutomationsForLead($event, MessageAutomationEventType::LEAD_CREATED, true);
}
/**
* @param AutomationEvent $event
*/
public function onEmailOpportunityCreated(AutomationEvent $event): void
{
$this->dispatchAutomationsForCustomer($event, MessageAutomationEventType::OPPORTUNITY_CREATED, false);
}
/**
* @param AutomationEvent $event
*/
public function onEmailCustomerCreated(AutomationEvent $event): void
{
$this->dispatchAutomationsForCustomer($event, MessageAutomationEventType::CUSTOMER_CREATED, true);
}
/**
* {@inheritdoc}
*/
protected function parseHtmlBody(string $htmlBody): string
{
return (new CssToInlineStyles())->convert($htmlBody);
}
/**
* @param AutomationEvent $event
* @param string $eventType
* @param bool $mustBeEnabled
*/
private function dispatchAutomationsForProspect(AutomationEvent $event, string $eventType, bool $mustBeEnabled): void
{
/** @var Prospect $prospect */
$prospect = $event->getObject();
if (!$prospect instanceof Prospect) {
return;
}
if ((bool) $prospect->isEnabled() !== (bool) $mustBeEnabled) {
return;
}
$this->dispatchAutomations(
$eventType,
$event->getOffsetDays(),
$prospect,
$prospect->getEmail(),
$prospect->getPhoneNumber(),
$event->getAutomationIds()
);
}
/**
* @param AutomationEvent $event
* @param string $eventType
* @param bool $mustBeEnabled
*/
private function dispatchAutomationsForLead(AutomationEvent $event, string $eventType, bool $mustBeEnabled): void
{
/** @var Lead $lead */
$lead = $event->getObject();
if (!$lead instanceof Lead) {
return;
}
if ((bool) $lead->isEnabled() !== (bool) $mustBeEnabled) {
return;
}
$this->dispatchAutomations(
$eventType,
$event->getOffsetDays(),
$lead,
$lead->getEmail(),
$lead->getPhoneNumber() ?: $lead->getPhoneNumber2(),
$event->getAutomationIds()
);
}
/**
* @param AutomationEvent $event
* @param string $eventType
* @param bool $mustBeCustomer
*/
private function dispatchAutomationsForCustomer(AutomationEvent $event, string $eventType, bool $mustBeCustomer): void
{
/** @var Customer\Customer $customer */
$customer = $event->getObject();
if (!$customer instanceof Customer\Customer) {
return;
}
if ((bool) $customer->isCustomer() !== (bool) $mustBeCustomer) {
return;
}
$this->dispatchAutomations(
$eventType,
$event->getOffsetDays(),
$customer,
$customer->getEmail(),
$customer->getMobileNumber() ?: $customer->getPhoneNumber(),
$event->getAutomationIds()
);
}
/**
* Core automation dispatcher for a single domain object.
*
* @param string $eventType
* @param int $offsetDays
* @param object $entity
* @param string|null $email
* @param string|null $phone
* @param array|null $automationIds
*/
private function dispatchAutomations(
string $eventType,
int $offsetDays,
$entity,
string $email = null,
string $phone = null,
array $automationIds = null
): void {
$email = AppUtil::normalizeEmail($email); // returns null when invalid
$phone = AppUtil::normalizePhone($phone); // returns null when invalid
if (empty($automationIds)) {
return;
}
// 6=Sat, 7=Sun
$isWeekend = (int) (new \DateTime())->format('N') >= 6;
$criteria = [
'enabled' => true,
'event' => $eventType,
'offsetDays' => $offsetDays,
'id' => $automationIds,
];
// weekend: only automations that opted-in to weekends
if ($isWeekend) {
$criteria['includeWeekends'] = true; // or 1
}
$automations = $this->managerRegistry
->getRepository(MessageAutomation::class)
->searchAll($criteria);
if (empty($automations)) {
return;
}
// map merge fields
$data = $this->mergeFields->mapValues($entity, null, true);
// render merge-field HTML into stored subject/body
$render = static function (string $text = null) use ($data): ?string {
if (null === $text || '' === $text) {
return $text;
}
foreach ($data as $key => $value) {
$text = preg_replace(sprintf('/<span class="text-editor-merge-field-container"><span class="text-editor-merge-field is-recipient"[^>]*data-name="%s"[^>]*># %s<\/span><\/span>/isU', $key, $key), str_replace('$', '\$', $value), $text);
}
return $text;
};
foreach ($automations as $automation) {
$channel = $automation->getChannel() ?: MessageAutomationChannelType::EMAIL;
if (!$automation->getBody()) {
$recipient = MessageAutomationChannelType::EMAIL === $channel ? $email : $phone;
$this->createSkippedLog($automation, $entity, $offsetDays, $eventType, $channel, $recipient, null, 'Empty body');
continue;
}
// EMAIL
if (MessageAutomationChannelType::EMAIL === $channel) {
if (!$email || !$this->canSendEmail($entity)
|| !$phone || !$this->canSendSms($entity)
) {
// do not log (noise / spam)
continue;
}
$subject = $render($automation->getSubject());
$body = $render($automation->getBody());
$log = $this->createLog($automation, $entity, $offsetDays, $email, $eventType, $channel, $subject, $body);
if (!$log) {
continue; // already logged/attempted
}
$token = $this->unsubscribeToken->create($entity->getId(), MessageAutomationChannelType::EMAIL);
$unsubscribeUrl = $this->urlGenerator->generate('unsubscribe', ['t' => $token], UrlGeneratorInterface::ABSOLUTE_URL);
try {
switch ($eventType) {
case MessageAutomationEventType::OPPORTUNITY_CREATED:
case MessageAutomationEventType::CUSTOMER_CREATED:
$sender = $this->sender;
$templateName = 'admin/messages/email.txt.twig';
// placeholder message
$message = new Customer\Message();
$message->setSubject($subject);
$message->setBody($body);
$context = [
'message' => $message,
'unsubscribeUrl' => $unsubscribeUrl,
];
break;
case MessageAutomationEventType::LEAD_CREATED:
default:
$sender = $this->automationsSender;
$templateName = 'admin/settings/message_automations_email.txt.twig';
$context = [
'event' => $eventType,
'subject' => $subject,
'body' => $body,
'unsubscribeUrl' => $unsubscribeUrl,
];
}
$this->sendMessage($templateName, $context, $email, $sender);
$this->markLogSent($log);
} catch (\Throwable $e) {
$this->markLogFailed($log, $e);
}
continue;
}
// SMS
if (MessageAutomationChannelType::SMS === $channel) {
if (!$phone || !$this->canSendSms($entity)) {
// do not log (noise / spam)
continue;
}
$body = $render($automation->getBody());
$text = (new Html2Text($body))->getText();
$log = $this->createLog($automation, $entity, $offsetDays, $phone, $eventType, $channel, null, $body);
if (!$log) {
continue;
}
try {
$this->callFire->sendText($text, $phone);
$this->markLogSent($log);
} catch (\Throwable $e) {
$this->markLogFailed($log, $e);
}
}
}
}
/**
* @param MessageAutomation $automation
* @param object $entity
* @param int $offsetDays
* @param string|null $eventType
* @param string|null $channel
* @param string|null $recipient
* @param string|null $body
* @param string|null $reason
*/
private function createSkippedLog(
MessageAutomation $automation,
$entity,
int $offsetDays,
string $eventType = null,
string $channel = null,
string $recipient = null,
string $body = null,
string $reason = null
): void {
$this->createLog(
$automation,
$entity,
$offsetDays,
$recipient,
$eventType,
$channel,
null,
$body,
MessageAutomationStatusType::SKIPPED,
$reason
);
}
/**
* @param MessageAutomation $automation
* @param object $entity
* @param int $offsetDays
* @param string|null $recipient
* @param string|null $eventType
* @param string|null $channel
* @param string|null $subject
* @param string|null $body
* @param string|null $status
* @param string|null $error
*
* @return MessageAutomationLog|null
*/
private function createLog(
MessageAutomation $automation,
$entity,
int $offsetDays,
string $recipient = null,
string $eventType = null,
string $channel = null,
string $subject = null,
string $body = null,
string $status = MessageAutomationStatusType::PENDING,
string $error = null
): ?MessageAutomationLog {
if (!method_exists($entity, 'getId')) {
return null;
}
$eventType = $eventType ?: ($automation->getEvent() ?: MessageAutomationEventType::LEAD_CREATED);
$channel = $channel ?: ($automation->getChannel() ?: MessageAutomationChannelType::EMAIL);
$entityClass = \get_class($entity);
$entityId = (string) $entity->getId();
$entityName = method_exists($entity, '__toString') ? (string) $entity : null;
$criteria = [
'automation' => $automation,
'entityClass' => $entityClass,
'entityId' => $entityId,
'offsetDays' => $offsetDays,
'event' => $eventType,
'channel' => $channel,
'recipient' => $recipient,
];
/** @var MessageAutomationLog|null $existing */
$existing = $this->managerRegistry
->getRepository(MessageAutomationLog::class)
->findOneBy($criteria);
if ($existing) {
// SKIPPED should only dedupe SKIPPED against SKIPPED
if (MessageAutomationStatusType::SKIPPED === $status) {
return (MessageAutomationStatusType::SKIPPED === $existing->getStatus()) ? null : null;
}
// - if already SENT or PENDING -> don't retry / don't duplicate
if (\in_array($existing->getStatus(), [MessageAutomationStatusType::SENT, MessageAutomationStatusType::PENDING], true)) {
return null;
}
// - if FAILED, allow retry up to 3 times and only when due
if (MessageAutomationStatusType::FAILED === $existing->getStatus()) {
$attempts = (int) $existing->getAttempts();
if ($attempts >= 3) {
return null; // permanent stop after 3
}
$now = new \DateTime();
$nextAttemptAt = $existing->getNextAttemptAt();
if ($nextAttemptAt instanceof \DateTimeInterface && $nextAttemptAt > $now) {
return null; // not yet due
}
// reuse existing row as a new attempt
$existing->setStatus(MessageAutomationStatusType::PENDING);
$existing->setError(null);
$existing->setNextAttemptAt(null);
// keep latest rendered content for this attempt (optional but helpful)
$existing->setSubject($subject);
$existing->setBody($body);
$this->managerRegistry
->getRepository(MessageAutomationLog::class)
->update($existing);
return $existing;
}
// any other status: do nothing
return null;
}
// create new log row (first attempt)
$log = new MessageAutomationLog();
$log->setAutomation($automation);
$log->setEntityClass($entityClass);
$log->setEntityId($entityId);
$log->setEntityName($entityName);
$log->setOffsetDays($offsetDays);
$log->setEvent($eventType);
$log->setChannel($channel);
$log->setRecipient($recipient);
$log->setSubject($subject);
$log->setBody($body);
$log->setStatus($status);
// first attempt counter is 0 until we fail; you can set explicitly if you want
$log->setAttempts(0);
$log->setNextAttemptAt(null);
if ($error) {
$log->setError(substr($error, 0, 5000));
}
$this->managerRegistry
->getRepository(MessageAutomationLog::class)
->update($log);
return $log;
}
/**
* @param MessageAutomationLog|null $log
*/
private function markLogSent(MessageAutomationLog $log = null): void
{
if (!$log) {
return;
}
$log->setStatus(MessageAutomationStatusType::SENT);
$log->setSentAt(new \DateTime());
$log->setError(null);
$log->setNextAttemptAt(null); // important
$this->managerRegistry
->getRepository(MessageAutomationLog::class)
->update($log);
}
/**
* @param MessageAutomationLog|null $log
* @param \Throwable $e
*/
private function markLogFailed(MessageAutomationLog $log = null, \Throwable $e): void
{
if (!$log) {
return;
}
$attempts = (int) $log->getAttempts();
++$attempts;
$log->setAttempts($attempts);
$log->setStatus(MessageAutomationStatusType::FAILED);
$log->setSentAt(new \DateTime());
$log->setError(substr($e->getMessage(), 0, 5000));
if ($attempts < 3) {
// retry in 30 minutes
$log->setNextAttemptAt((new \DateTime())->modify('+30 minutes'));
} else {
// stop retrying after 3 attempts
$log->setNextAttemptAt(null);
}
$this->managerRegistry
->getRepository(MessageAutomationLog::class)
->update($log);
}
/**
* @param object $entity
*
* @return bool
*/
private function canSendEmail($entity): bool
{
if (!\is_callable([$entity, 'isEmailUnsubscribed'])) {
return true; // fail-open
}
return !$entity->isEmailUnsubscribed();
}
/**
* @param object $entity
*
* @return bool
*/
private function canSendSms($entity): bool
{
if (!\is_callable([$entity, 'isSmsUnsubscribed'])) {
return true; // fail-open
}
return !$entity->isSmsUnsubscribed();
}
/**
* @param string $sender
*
* @return bool
*/
private function isNullSender(string $sender = null): bool
{
if (null === $sender) {
return true;
}
$s = trim($sender);
if ('' === $s) {
return true;
}
$lower = strtolower($s);
if (\in_array($lower, ['null', '(null)', 'none', 'n/a', 'na', 'undefined'], true)) {
return true;
}
// if formatted like "Name <email@domain>"
if (preg_match('/<\s*([^>]+)\s*>/', $s, $m)) {
$email = trim($m[1]);
// common "null-ish" expansions
$emailLower = strtolower($email);
if ('' === $email || \in_array($emailLower, ['null', '(null)', 'none', 'undefined'], true)) {
return true;
}
// empty brackets: "Name <>" or "Name < >"
if ('' === $email) {
return true;
}
// validate email
if (false === filter_var($email, \FILTER_VALIDATE_EMAIL)) {
return true;
}
return false;
}
// if it's just an email address (no name)
if (false !== filter_var($s, \FILTER_VALIDATE_EMAIL)) {
return false;
}
// if it's some other string without a valid email, treat as unset
return true;
}
}