src/EventSubscriber/EmailSubscriber.php line 184

Open in your IDE?
  1. <?php
  2. namespace App\EventSubscriber;
  3. use App\DBAL\Types\MessageAutomationChannelType;
  4. use App\DBAL\Types\MessageAutomationEventType;
  5. use App\DBAL\Types\MessageAutomationStatusType;
  6. use App\Entity\Customer;
  7. use App\Entity\Lead\Lead;
  8. use App\Entity\Lead\Prospect;
  9. use App\Entity\MessageAutomation;
  10. use App\Entity\MessageAutomationLog;
  11. use App\Event\AutomationEvent;
  12. use App\Events;
  13. use App\Service\CallFire;
  14. use App\Service\MergeFields;
  15. use App\Service\UnsubscribeToken;
  16. use App\Util\AppUtil;
  17. use Doctrine\Persistence\ManagerRegistry;
  18. use EWZ\SymfonyAdminBundle\Event\ObjectEvent;
  19. use EWZ\SymfonyAdminBundle\EventSubscriber\EmailSubscriber as BaseEmailSubscriber;
  20. use Html2Text\Html2Text;
  21. use Symfony\Component\Mailer\MailerInterface;
  22. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  23. use Symfony\Contracts\Translation\TranslatorInterface;
  24. use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
  25. use Twig\Environment as TwigEnvironment;
  26. /**
  27.  * Emails events.
  28.  */
  29. class EmailSubscriber extends BaseEmailSubscriber
  30. {
  31.     /** @var ManagerRegistry */
  32.     private $managerRegistry;
  33.     /** @var TranslatorInterface */
  34.     private $translator;
  35.     /** @var MergeFields */
  36.     private $mergeFields;
  37.     /** @var CallFire */
  38.     private $callFire;
  39.     /** @var UnsubscribeToken */
  40.     private $unsubscribeToken;
  41.     /** @var string */
  42.     private $automationsSender;
  43.     /**
  44.      * @param TwigEnvironment       $twig
  45.      * @param MailerInterface       $mailer
  46.      * @param UrlGeneratorInterface $urlGenerator
  47.      * @param ManagerRegistry       $managerRegistry
  48.      * @param TranslatorInterface   $translator
  49.      * @param MergeFields           $mergeFields
  50.      * @param CallFire              $callFire
  51.      * @param UnsubscribeToken      $unsubscribeToken
  52.      * @param string                $sender
  53.      * @param string|null           $automationsSender
  54.      */
  55.     public function __construct(
  56.         TwigEnvironment $twig,
  57.         MailerInterface $mailer,
  58.         UrlGeneratorInterface $urlGenerator,
  59.         ManagerRegistry $managerRegistry,
  60.         TranslatorInterface $translator,
  61.         MergeFields $mergeFields,
  62.         CallFire $callFire,
  63.         UnsubscribeToken $unsubscribeToken,
  64.         string $sender,
  65.         string $automationsSender null
  66.     ) {
  67.         parent::__construct($twig$mailer$urlGenerator$sender);
  68.         $this->managerRegistry $managerRegistry;
  69.         $this->translator $translator;
  70.         $this->mergeFields $mergeFields;
  71.         $this->callFire $callFire;
  72.         $this->unsubscribeToken $unsubscribeToken;
  73.         $this->automationsSender $automationsSender;
  74.         // override
  75.         if ($this->isNullSender($this->automationsSender)) {
  76.             $this->automationsSender $sender;
  77.         }
  78.     }
  79.     /**
  80.      * {@inheritdoc}
  81.      */
  82.     public static function getSubscribedEvents(): array
  83.     {
  84.         return array_merge(
  85.             parent::getSubscribedEvents(),
  86.             [
  87.                 Events::EMAIL_CLIENT_LOGIN_CREDENTIAL => 'onEmailClientLoginCredential',
  88.                 Events::EMAIL_CUSTOMER_MESSAGE => 'onEmailCustomerMessage',
  89.                 // message automations
  90.                 Events::EMAIL_PROSPECT_CREATED => 'onEmailProspectCreated',
  91.                 Events::EMAIL_LEAD_CREATED => 'onEmailLeadCreated',
  92.                 Events::EMAIL_OPPORTUNITY_CREATED => 'onEmailOpportunityCreated',
  93.                 Events::EMAIL_CUSTOMER_CREATED => 'onEmailCustomerCreated',
  94.             ]
  95.         );
  96.     }
  97.     /**
  98.      * @param ObjectEvent $event
  99.      */
  100.     public function onEmailClientLoginCredential(ObjectEvent $event): void
  101.     {
  102.         /** @var Customer\Customer $customer */
  103.         $customer $event->getObject();
  104.         $url $this->urlGenerator->generate('client_security_login', [], UrlGeneratorInterface::ABSOLUTE_URL);
  105.         $context = [
  106.             'url' => $url,
  107.             'customer' => $customer,
  108.         ];
  109.         $this->sendMessage('client/resetting/email.txt.twig'$context$customer->getEmail());
  110.     }
  111.     /**
  112.      * @param ObjectEvent $event
  113.      */
  114.     public function onEmailCustomerMessage(ObjectEvent $event): void
  115.     {
  116.         /** @var Customer\Message $message */
  117.         $message $event->getObject();
  118.         $context = [
  119.             'message' => $message,
  120.         ];
  121.         foreach ($message->getContacts() as $contact) {
  122.             if ($message->isEmailContact($contact)) {
  123.                 $this->sendMessage('admin/messages/email.txt.twig'$context$contact);
  124.             }
  125.             if ($message->isPhoneContact($contact)) {
  126.                 $html = new Html2Text($message->getBody());
  127.                 try {
  128.                     $this->callFire->sendText($html->getText(), $contact);
  129.                 } catch (\Exception $e) {
  130.                 }
  131.             }
  132.         }
  133.     }
  134.     /**
  135.      * @param AutomationEvent $event
  136.      */
  137.     public function onEmailProspectCreated(AutomationEvent $event): void
  138.     {
  139.         $this->dispatchAutomationsForProspect($eventMessageAutomationEventType::PROSPECT_CREATEDtrue);
  140.     }
  141.     /**
  142.      * @param AutomationEvent $event
  143.      */
  144.     public function onEmailLeadCreated(AutomationEvent $event): void
  145.     {
  146.         $this->dispatchAutomationsForLead($eventMessageAutomationEventType::LEAD_CREATEDtrue);
  147.     }
  148.     /**
  149.      * @param AutomationEvent $event
  150.      */
  151.     public function onEmailOpportunityCreated(AutomationEvent $event): void
  152.     {
  153.         $this->dispatchAutomationsForCustomer($eventMessageAutomationEventType::OPPORTUNITY_CREATEDfalse);
  154.     }
  155.     /**
  156.      * @param AutomationEvent $event
  157.      */
  158.     public function onEmailCustomerCreated(AutomationEvent $event): void
  159.     {
  160.         $this->dispatchAutomationsForCustomer($eventMessageAutomationEventType::CUSTOMER_CREATEDtrue);
  161.     }
  162.     /**
  163.      * {@inheritdoc}
  164.      */
  165.     protected function parseHtmlBody(string $htmlBody): string
  166.     {
  167.         return (new CssToInlineStyles())->convert($htmlBody);
  168.     }
  169.     /**
  170.      * @param AutomationEvent $event
  171.      * @param string          $eventType
  172.      * @param bool            $mustBeEnabled
  173.      */
  174.     private function dispatchAutomationsForProspect(AutomationEvent $eventstring $eventTypebool $mustBeEnabled): void
  175.     {
  176.         /** @var Prospect $prospect */
  177.         $prospect $event->getObject();
  178.         if (!$prospect instanceof Prospect) {
  179.             return;
  180.         }
  181.         if ((bool) $prospect->isEnabled() !== (bool) $mustBeEnabled) {
  182.             return;
  183.         }
  184.         $this->dispatchAutomations(
  185.             $eventType,
  186.             $event->getOffsetDays(),
  187.             $prospect,
  188.             $prospect->getEmail(),
  189.             $prospect->getPhoneNumber(),
  190.             $event->getAutomationIds()
  191.         );
  192.     }
  193.     /**
  194.      * @param AutomationEvent $event
  195.      * @param string          $eventType
  196.      * @param bool            $mustBeEnabled
  197.      */
  198.     private function dispatchAutomationsForLead(AutomationEvent $eventstring $eventTypebool $mustBeEnabled): void
  199.     {
  200.         /** @var Lead $lead */
  201.         $lead $event->getObject();
  202.         if (!$lead instanceof Lead) {
  203.             return;
  204.         }
  205.         if ((bool) $lead->isEnabled() !== (bool) $mustBeEnabled) {
  206.             return;
  207.         }
  208.         $this->dispatchAutomations(
  209.             $eventType,
  210.             $event->getOffsetDays(),
  211.             $lead,
  212.             $lead->getEmail(),
  213.             $lead->getPhoneNumber() ?: $lead->getPhoneNumber2(),
  214.             $event->getAutomationIds()
  215.         );
  216.     }
  217.     /**
  218.      * @param AutomationEvent $event
  219.      * @param string          $eventType
  220.      * @param bool            $mustBeCustomer
  221.      */
  222.     private function dispatchAutomationsForCustomer(AutomationEvent $eventstring $eventTypebool $mustBeCustomer): void
  223.     {
  224.         /** @var Customer\Customer $customer */
  225.         $customer $event->getObject();
  226.         if (!$customer instanceof Customer\Customer) {
  227.             return;
  228.         }
  229.         if ((bool) $customer->isCustomer() !== (bool) $mustBeCustomer) {
  230.             return;
  231.         }
  232.         $this->dispatchAutomations(
  233.             $eventType,
  234.             $event->getOffsetDays(),
  235.             $customer,
  236.             $customer->getEmail(),
  237.             $customer->getMobileNumber() ?: $customer->getPhoneNumber(),
  238.             $event->getAutomationIds()
  239.         );
  240.     }
  241.     /**
  242.      * Core automation dispatcher for a single domain object.
  243.      *
  244.      * @param string      $eventType
  245.      * @param int         $offsetDays
  246.      * @param object      $entity
  247.      * @param string|null $email
  248.      * @param string|null $phone
  249.      * @param array|null  $automationIds
  250.      */
  251.     private function dispatchAutomations(
  252.         string $eventType,
  253.         int $offsetDays,
  254.         $entity,
  255.         string $email null,
  256.         string $phone null,
  257.         array $automationIds null
  258.     ): void {
  259.         $email AppUtil::normalizeEmail($email); // returns null when invalid
  260.         $phone AppUtil::normalizePhone($phone); // returns null when invalid
  261.         if (empty($automationIds)) {
  262.             return;
  263.         }
  264.         // 6=Sat, 7=Sun
  265.         $isWeekend = (int) (new \DateTime())->format('N') >= 6;
  266.         $criteria = [
  267.             'enabled' => true,
  268.             'event' => $eventType,
  269.             'offsetDays' => $offsetDays,
  270.             'id' => $automationIds,
  271.         ];
  272.         // weekend: only automations that opted-in to weekends
  273.         if ($isWeekend) {
  274.             $criteria['includeWeekends'] = true// or 1
  275.         }
  276.         $automations $this->managerRegistry
  277.             ->getRepository(MessageAutomation::class)
  278.             ->searchAll($criteria);
  279.         if (empty($automations)) {
  280.             return;
  281.         }
  282.         // map merge fields
  283.         $data $this->mergeFields->mapValues($entitynulltrue);
  284.         // render merge-field HTML into stored subject/body
  285.         $render = static function (string $text null) use ($data): ?string {
  286.             if (null === $text || '' === $text) {
  287.                 return $text;
  288.             }
  289.             foreach ($data as $key => $value) {
  290.                 $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);
  291.             }
  292.             return $text;
  293.         };
  294.         foreach ($automations as $automation) {
  295.             $channel $automation->getChannel() ?: MessageAutomationChannelType::EMAIL;
  296.             if (!$automation->getBody()) {
  297.                 $recipient MessageAutomationChannelType::EMAIL === $channel $email $phone;
  298.                 $this->createSkippedLog($automation$entity$offsetDays$eventType$channel$recipientnull'Empty body');
  299.                 continue;
  300.             }
  301.             // EMAIL
  302.             if (MessageAutomationChannelType::EMAIL === $channel) {
  303.                 if (!$email || !$this->canSendEmail($entity)
  304.                     || !$phone || !$this->canSendSms($entity)
  305.                 ) {
  306.                     // do not log (noise / spam)
  307.                     continue;
  308.                 }
  309.                 $subject $render($automation->getSubject());
  310.                 $body $render($automation->getBody());
  311.                 $log $this->createLog($automation$entity$offsetDays$email$eventType$channel$subject$body);
  312.                 if (!$log) {
  313.                     continue; // already logged/attempted
  314.                 }
  315.                 $token $this->unsubscribeToken->create($entity->getId(), MessageAutomationChannelType::EMAIL);
  316.                 $unsubscribeUrl $this->urlGenerator->generate('unsubscribe', ['t' => $token], UrlGeneratorInterface::ABSOLUTE_URL);
  317.                 try {
  318.                     switch ($eventType) {
  319.                         case MessageAutomationEventType::OPPORTUNITY_CREATED:
  320.                         case MessageAutomationEventType::CUSTOMER_CREATED:
  321.                             $sender $this->sender;
  322.                             $templateName 'admin/messages/email.txt.twig';
  323.                             // placeholder message
  324.                             $message = new Customer\Message();
  325.                             $message->setSubject($subject);
  326.                             $message->setBody($body);
  327.                             $context = [
  328.                                 'message' => $message,
  329.                                 'unsubscribeUrl' => $unsubscribeUrl,
  330.                             ];
  331.                             break;
  332.                         case MessageAutomationEventType::LEAD_CREATED:
  333.                         default:
  334.                             $sender $this->automationsSender;
  335.                             $templateName 'admin/settings/message_automations_email.txt.twig';
  336.                             $context = [
  337.                                 'event' => $eventType,
  338.                                 'subject' => $subject,
  339.                                 'body' => $body,
  340.                                 'unsubscribeUrl' => $unsubscribeUrl,
  341.                             ];
  342.                     }
  343.                     $this->sendMessage($templateName$context$email$sender);
  344.                     $this->markLogSent($log);
  345.                 } catch (\Throwable $e) {
  346.                     $this->markLogFailed($log$e);
  347.                 }
  348.                 continue;
  349.             }
  350.             // SMS
  351.             if (MessageAutomationChannelType::SMS === $channel) {
  352.                 if (!$phone || !$this->canSendSms($entity)) {
  353.                     // do not log (noise / spam)
  354.                     continue;
  355.                 }
  356.                 $body $render($automation->getBody());
  357.                 $text = (new Html2Text($body))->getText();
  358.                 $log $this->createLog($automation$entity$offsetDays$phone$eventType$channelnull$body);
  359.                 if (!$log) {
  360.                     continue;
  361.                 }
  362.                 try {
  363.                     $this->callFire->sendText($text$phone);
  364.                     $this->markLogSent($log);
  365.                 } catch (\Throwable $e) {
  366.                     $this->markLogFailed($log$e);
  367.                 }
  368.             }
  369.         }
  370.     }
  371.     /**
  372.      * @param MessageAutomation $automation
  373.      * @param object            $entity
  374.      * @param int               $offsetDays
  375.      * @param string|null       $eventType
  376.      * @param string|null       $channel
  377.      * @param string|null       $recipient
  378.      * @param string|null       $body
  379.      * @param string|null       $reason
  380.      */
  381.     private function createSkippedLog(
  382.         MessageAutomation $automation,
  383.         $entity,
  384.         int $offsetDays,
  385.         string $eventType null,
  386.         string $channel null,
  387.         string $recipient null,
  388.         string $body null,
  389.         string $reason null
  390.     ): void {
  391.         $this->createLog(
  392.             $automation,
  393.             $entity,
  394.             $offsetDays,
  395.             $recipient,
  396.             $eventType,
  397.             $channel,
  398.             null,
  399.             $body,
  400.             MessageAutomationStatusType::SKIPPED,
  401.             $reason
  402.         );
  403.     }
  404.     /**
  405.      * @param MessageAutomation $automation
  406.      * @param object            $entity
  407.      * @param int               $offsetDays
  408.      * @param string|null       $recipient
  409.      * @param string|null       $eventType
  410.      * @param string|null       $channel
  411.      * @param string|null       $subject
  412.      * @param string|null       $body
  413.      * @param string|null       $status
  414.      * @param string|null       $error
  415.      *
  416.      * @return MessageAutomationLog|null
  417.      */
  418.     private function createLog(
  419.         MessageAutomation $automation,
  420.         $entity,
  421.         int $offsetDays,
  422.         string $recipient null,
  423.         string $eventType null,
  424.         string $channel null,
  425.         string $subject null,
  426.         string $body null,
  427.         string $status MessageAutomationStatusType::PENDING,
  428.         string $error null
  429.     ): ?MessageAutomationLog {
  430.         if (!method_exists($entity'getId')) {
  431.             return null;
  432.         }
  433.         $eventType $eventType ?: ($automation->getEvent() ?: MessageAutomationEventType::LEAD_CREATED);
  434.         $channel $channel ?: ($automation->getChannel() ?: MessageAutomationChannelType::EMAIL);
  435.         $entityClass = \get_class($entity);
  436.         $entityId = (string) $entity->getId();
  437.         $entityName method_exists($entity'__toString') ? (string) $entity null;
  438.         $criteria = [
  439.             'automation' => $automation,
  440.             'entityClass' => $entityClass,
  441.             'entityId' => $entityId,
  442.             'offsetDays' => $offsetDays,
  443.             'event' => $eventType,
  444.             'channel' => $channel,
  445.             'recipient' => $recipient,
  446.         ];
  447.         /** @var MessageAutomationLog|null $existing */
  448.         $existing $this->managerRegistry
  449.             ->getRepository(MessageAutomationLog::class)
  450.             ->findOneBy($criteria);
  451.         if ($existing) {
  452.             // SKIPPED should only dedupe SKIPPED against SKIPPED
  453.             if (MessageAutomationStatusType::SKIPPED === $status) {
  454.                 return (MessageAutomationStatusType::SKIPPED === $existing->getStatus()) ? null null;
  455.             }
  456.             // - if already SENT or PENDING -> don't retry / don't duplicate
  457.             if (\in_array($existing->getStatus(), [MessageAutomationStatusType::SENTMessageAutomationStatusType::PENDING], true)) {
  458.                 return null;
  459.             }
  460.             // - if FAILED, allow retry up to 3 times and only when due
  461.             if (MessageAutomationStatusType::FAILED === $existing->getStatus()) {
  462.                 $attempts = (int) $existing->getAttempts();
  463.                 if ($attempts >= 3) {
  464.                     return null// permanent stop after 3
  465.                 }
  466.                 $now = new \DateTime();
  467.                 $nextAttemptAt $existing->getNextAttemptAt();
  468.                 if ($nextAttemptAt instanceof \DateTimeInterface && $nextAttemptAt $now) {
  469.                     return null// not yet due
  470.                 }
  471.                 // reuse existing row as a new attempt
  472.                 $existing->setStatus(MessageAutomationStatusType::PENDING);
  473.                 $existing->setError(null);
  474.                 $existing->setNextAttemptAt(null);
  475.                 // keep latest rendered content for this attempt (optional but helpful)
  476.                 $existing->setSubject($subject);
  477.                 $existing->setBody($body);
  478.                 $this->managerRegistry
  479.                     ->getRepository(MessageAutomationLog::class)
  480.                     ->update($existing);
  481.                 return $existing;
  482.             }
  483.             // any other status: do nothing
  484.             return null;
  485.         }
  486.         // create new log row (first attempt)
  487.         $log = new MessageAutomationLog();
  488.         $log->setAutomation($automation);
  489.         $log->setEntityClass($entityClass);
  490.         $log->setEntityId($entityId);
  491.         $log->setEntityName($entityName);
  492.         $log->setOffsetDays($offsetDays);
  493.         $log->setEvent($eventType);
  494.         $log->setChannel($channel);
  495.         $log->setRecipient($recipient);
  496.         $log->setSubject($subject);
  497.         $log->setBody($body);
  498.         $log->setStatus($status);
  499.         // first attempt counter is 0 until we fail; you can set explicitly if you want
  500.         $log->setAttempts(0);
  501.         $log->setNextAttemptAt(null);
  502.         if ($error) {
  503.             $log->setError(substr($error05000));
  504.         }
  505.         $this->managerRegistry
  506.             ->getRepository(MessageAutomationLog::class)
  507.             ->update($log);
  508.         return $log;
  509.     }
  510.     /**
  511.      * @param MessageAutomationLog|null $log
  512.      */
  513.     private function markLogSent(MessageAutomationLog $log null): void
  514.     {
  515.         if (!$log) {
  516.             return;
  517.         }
  518.         $log->setStatus(MessageAutomationStatusType::SENT);
  519.         $log->setSentAt(new \DateTime());
  520.         $log->setError(null);
  521.         $log->setNextAttemptAt(null); // important
  522.         $this->managerRegistry
  523.             ->getRepository(MessageAutomationLog::class)
  524.             ->update($log);
  525.     }
  526.     /**
  527.      * @param MessageAutomationLog|null $log
  528.      * @param \Throwable                $e
  529.      */
  530.     private function markLogFailed(MessageAutomationLog $log null, \Throwable $e): void
  531.     {
  532.         if (!$log) {
  533.             return;
  534.         }
  535.         $attempts = (int) $log->getAttempts();
  536.         ++$attempts;
  537.         $log->setAttempts($attempts);
  538.         $log->setStatus(MessageAutomationStatusType::FAILED);
  539.         $log->setSentAt(new \DateTime());
  540.         $log->setError(substr($e->getMessage(), 05000));
  541.         if ($attempts 3) {
  542.             // retry in 30 minutes
  543.             $log->setNextAttemptAt((new \DateTime())->modify('+30 minutes'));
  544.         } else {
  545.             // stop retrying after 3 attempts
  546.             $log->setNextAttemptAt(null);
  547.         }
  548.         $this->managerRegistry
  549.             ->getRepository(MessageAutomationLog::class)
  550.             ->update($log);
  551.     }
  552.     /**
  553.      * @param object $entity
  554.      *
  555.      * @return bool
  556.      */
  557.     private function canSendEmail($entity): bool
  558.     {
  559.         if (!\is_callable([$entity'isEmailUnsubscribed'])) {
  560.             return true// fail-open
  561.         }
  562.         return !$entity->isEmailUnsubscribed();
  563.     }
  564.     /**
  565.      * @param object $entity
  566.      *
  567.      * @return bool
  568.      */
  569.     private function canSendSms($entity): bool
  570.     {
  571.         if (!\is_callable([$entity'isSmsUnsubscribed'])) {
  572.             return true// fail-open
  573.         }
  574.         return !$entity->isSmsUnsubscribed();
  575.     }
  576.     /**
  577.      * @param string $sender
  578.      *
  579.      * @return bool
  580.      */
  581.     private function isNullSender(string $sender null): bool
  582.     {
  583.         if (null === $sender) {
  584.             return true;
  585.         }
  586.         $s trim($sender);
  587.         if ('' === $s) {
  588.             return true;
  589.         }
  590.         $lower strtolower($s);
  591.         if (\in_array($lower, ['null''(null)''none''n/a''na''undefined'], true)) {
  592.             return true;
  593.         }
  594.         // if formatted like "Name <email@domain>"
  595.         if (preg_match('/<\s*([^>]+)\s*>/'$s$m)) {
  596.             $email trim($m[1]);
  597.             // common "null-ish" expansions
  598.             $emailLower strtolower($email);
  599.             if ('' === $email || \in_array($emailLower, ['null''(null)''none''undefined'], true)) {
  600.                 return true;
  601.             }
  602.             // empty brackets: "Name <>" or "Name < >"
  603.             if ('' === $email) {
  604.                 return true;
  605.             }
  606.             // validate email
  607.             if (false === filter_var($email, \FILTER_VALIDATE_EMAIL)) {
  608.                 return true;
  609.             }
  610.             return false;
  611.         }
  612.         // if it's just an email address (no name)
  613.         if (false !== filter_var($s, \FILTER_VALIDATE_EMAIL)) {
  614.             return false;
  615.         }
  616.         // if it's some other string without a valid email, treat as unset
  617.         return true;
  618.     }
  619. }