diff --git a/.env b/.env index fd054dfa..f10eb565 100644 --- a/.env +++ b/.env @@ -28,10 +28,11 @@ DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name ###< doctrine/doctrine-bundle ### ###> symfony/mailer ### -# MAILER_DSN=smtp://localhost +MAILER_DSN=smtp://localhost ###< symfony/mailer ### APP_NAME="Member Directory" +APP_BASE_URI= APP_LOGO= APP_EMAIL_TO= APP_EMAIL_FROM= diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml index a15c4ec6..bf8b9237 100644 --- a/config/packages/routing.yaml +++ b/config/packages/routing.yaml @@ -1,4 +1,5 @@ framework: router: + default_uri: '%env(resolve:APP_BASE_URI)%' strict_requirements: ~ utf8: true diff --git a/src/Command/EspWebhookCommand.php b/src/Command/EspWebhookCommand.php new file mode 100644 index 00000000..0eccedb5 --- /dev/null +++ b/src/Command/EspWebhookCommand.php @@ -0,0 +1,92 @@ +emailService = $emailService; + } + + protected function configure() + { + $this + ->setDescription(self::$defaultDescription) + ->addArgument('action', InputArgument::REQUIRED, 'Action to take (list, create, delete)') + ->addOption('webhook-id', null, InputOption::VALUE_OPTIONAL, 'The UUID for the webhook.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (!$this->emailService->isConfigured()) { + $io->error('Email Service not configured.'); + return Command::FAILURE; + } + + switch ($input->getArgument('action')) { + case 'list': + $webhooks = $this->emailService->getWebhooks(); + if (!is_array($webhooks) || count($webhooks) == 0) { + $io->info('No webhooks configured.'); + return Command::SUCCESS; + } + $table = new Table($output); + $rows = []; + foreach ($webhooks as $webhook) { + $rows[] = [ + $webhook->WebhookID, + $webhook->Url, + implode(', ', $webhook->Events), + $webhook->Status, + mb_strtoupper($webhook->PayloadFormat) + ]; + } + $table->setHeaders(['Webhook ID', 'URL', 'Events', 'Status', 'Format']); + $table->setRows($rows); + $table->render(); + break; + case 'create': + $webhook = $this->emailService->createWebhook(); + if (!$webhook) { + $io->error('Unable to create webhook.'); + return Command::FAILURE; + } + $io->success(sprintf('Created webhook: %s', $webhook)); + break; + case 'delete': + $webhookId = $input->getOption('webhook-id'); + if (!$webhookId) { + $io->error('You must provide a --webhook-id=somestring'); + return Command::FAILURE; + } + $result = $this->emailService->deleteWebhook($webhookId); + if (!$result) { + $io->error(sprintf('Unable to delete webhook: %s', $webhookId)); + return Command::FAILURE; + } + $io->success(sprintf('Deleted webhook: %s', $webhookId)); + break; + } + + return Command::SUCCESS; + } +} diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index b334ba0f..c1b7cbce 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -2,12 +2,10 @@ namespace App\Controller; +use App\Service\EmailService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Mime\Header\Headers; -use Symfony\Component\Mailer\MailerInterface; use App\Entity\Member; use App\Form\MemberUpdateType; @@ -35,7 +33,7 @@ public function updateFromQueryString(Request $request) /** * @Route("/update-my-info/{externalIdentifier}/{updateToken}", name="self_service_update") */ - public function update(Request $request, MailerInterface $mailer) + public function update(Request $request, EmailService $emailService) { $member = $this->getDoctrine()->getRepository(Member::class)->findOneBy([ 'externalIdentifier' => $request->get('externalIdentifier') @@ -55,25 +53,10 @@ public function update(Request $request, MailerInterface $mailer) $member = $form->getData(); // If form is submitted, member is no longer "lost" $member->setIsLost(false); - // Set headers for grouping in transactional email reporting - $headers = new Headers(); - $headers->addTextHeader('X-Cmail-GroupName', 'Member Record Update'); - $headers->addTextHeader('X-MC-Tags', 'Member Record Update'); - $message = new TemplatedEmail($headers); - $message - ->to($this->getParameter('app.email.to')) - ->from($this->getParameter('app.email.from')) - ->subject(sprintf('Member Record Update: %s', $member->getDisplayName())) - ->htmlTemplate('update/email_update.html.twig') - ->context(['member' => $member]) - ; - if ($member->getPrimaryEmail()) { - $message->replyTo($member->getPrimaryEmail()); - } $entityManager = $this->getDoctrine()->getManager(); $entityManager->persist($member); $entityManager->flush(); - $mailer->send($message); + $emailService->sendMemberUpdate($member); return $this->render('update/confirmation.html.twig'); } diff --git a/src/Controller/WebhookController.php b/src/Controller/WebhookController.php new file mode 100644 index 00000000..03775490 --- /dev/null +++ b/src/Controller/WebhookController.php @@ -0,0 +1,69 @@ +json([ + 'status' => 200, + 'title' => 'Success', + 'message' => 'Webhooks are available.' + ]); + } + + /** + * @Route("/webhook/email-service", name="webhook_email_service", methods={"POST"}) + */ + public function emailServiceWebhook(Request $request, EmailService $emailService): Response + { + // Fail if not configured + if (!$emailService->isConfigured()) { + return $this->json([ + 'status' => 500, + 'title' => 'Internal server error', + 'details' => 'Email service not configured.' + ], 500); + } + + // Fail if token is missing or mismatched + if (!$request->get('token') || + $request->get('token') != $emailService->getWebhookToken() + ) { + return $this->json([ + 'status' => 403, + 'title' => 'Access denied', + 'details' => 'Invalid credentials.' + ], 403); + } + + // Process payload + try { + $output = $emailService->processWebhookBody($request->getContent()); + return $this->json([ + 'status' => 200, + 'title' => 'success', + 'details' => 'Processed webhook.', + 'extra' => $output + ]); + } catch (\Exception $e) { + return $this->json([ + 'status' => 'error', + 'title' => 'Internal server error', + 'message' => $e->getMessage() + ], 500); + } + } + + +} diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php index 0f3c4e21..077f91a6 100644 --- a/src/Service/EmailService.php +++ b/src/Service/EmailService.php @@ -2,9 +2,15 @@ namespace App\Service; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use CS_REST_Subscribers; use CS_REST_Campaigns; +use CS_REST_Lists; +use CS_REST_Subscribers; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use App\Entity\Member; @@ -12,31 +18,42 @@ class EmailService { protected $params; - protected $apiKey; - - protected $defaultListId; + protected $router; - protected $client; + protected $subscribersClient; protected $campaignsClient; - public function __construct(ParameterBagInterface $params) + protected $listsClient; + + protected $em; + + protected $mailer; + + public function __construct(ParameterBagInterface $params, UrlGeneratorInterface $router, EntityManagerInterface $em, MailerInterface $mailer) { $this->params = $params; - $this->apiKey = $params->get('campaign_monitor.api_key'); - $this->defaultListId = $params->get('campaign_monitor.default_list_id'); - $this->client = new CS_REST_Subscribers( - $this->defaultListId, + $this->router = $router; + $this->subscribersClient = new CS_REST_Subscribers( + $params->get('campaign_monitor.default_list_id'), [ - 'api_key' => $this->apiKey + 'api_key' => $params->get('campaign_monitor.api_key') ] ); $this->campaignsClient = new CS_REST_Campaigns( - $this->defaultListId, + $params->get('campaign_monitor.default_list_id'), + [ + 'api_key' => $params->get('campaign_monitor.api_key') + ] + ); + $this->listsClient = new CS_REST_Lists( + $params->get('campaign_monitor.default_list_id'), [ - 'api_key' => $this->apiKey + 'api_key' => $params->get('campaign_monitor.api_key') ] ); + $this->em = $em; + $this->mailer = $mailer; } public function isConfigured(): bool @@ -52,7 +69,7 @@ public function getMemberSubscription(Member $member) if (!$member->getPrimaryEmail()) { return []; } - $result = $this->client->get($member->getPrimaryEmail(), true); + $result = $this->subscribersClient->get($member->getPrimaryEmail(), true); return $result->response; } @@ -61,7 +78,7 @@ public function getMemberSubscriptionHistory(Member $member) if (!$member->getPrimaryEmail()) { return []; } - $result = $this->client->get_history($member->getPrimaryEmail()); + $result = $this->subscribersClient->get_history($member->getPrimaryEmail()); return $result->response; } @@ -73,7 +90,7 @@ public function subscribeMember(Member $member, $resubscribe = false): bool ) { return false; } - $result = $this->client->add([ + $result = $this->subscribersClient->add([ 'EmailAddress' => $member->getPrimaryEmail(), 'Name' => $member->getDisplayName(), 'CustomFields' => $this->buildCustomFieldArray($member), @@ -92,7 +109,7 @@ public function updateMember(string $existingEmail, Member $member): bool if (!$member->getPrimaryEmail()) { return false; } - $result = $this->client->update($existingEmail, [ + $result = $this->subscribersClient->update($existingEmail, [ 'EmailAddress' => $member->getPrimaryEmail(), 'Name' => $member->getDisplayName(), 'CustomFields' => $this->buildCustomFieldArray($member), @@ -110,7 +127,7 @@ public function unsubscribeMember(Member $member): bool if (!$member->getPrimaryEmail()) { return false; } - $result = $this->client->unsubscribe($member->getPrimaryEmail()); + $result = $this->subscribersClient->unsubscribe($member->getPrimaryEmail()); if ($result->was_successful()) { return true; } @@ -123,7 +140,7 @@ public function deleteMember(Member $member): bool if (!$member->getPrimaryEmail()) { return false; } - $result = $this->client->delete($member->getPrimaryEmail()); + $result = $this->subscribersClient->delete($member->getPrimaryEmail()); if ($result->was_successful()) { return true; } @@ -138,6 +155,123 @@ public function getCampaignById($campaignId): object return $result->response; } + public function getWebhooks(): array + { + $result = $this->listsClient->get_webhooks(); + if ($result->was_successful()) { + return $result->response; + } + error_log(json_encode($result->response)); + return []; + } + + public function createWebhook(): ?string + { + if (!$this->getWebhookToken()) { + error_log('No Webhook Token configured.'); + return null; + } + + $result = $this->listsClient->create_webhook(array( + 'Events' => array(CS_REST_LIST_WEBHOOK_SUBSCRIBE, CS_REST_LIST_WEBHOOK_DEACTIVATE, CS_REST_LIST_WEBHOOK_UPDATE), + 'Url' => $this->router->generate( + 'webhook_email_service', + [ + 'token' => $this->getWebhookToken() + ], + UrlGeneratorInterface::ABSOLUTE_URL + ), + 'PayloadFormat' => CS_REST_WEBHOOK_FORMAT_JSON + )); + if ($result->was_successful()) { + return $result->response; + } + error_log(json_encode($result->response)); + return null; + } + + public function deleteWebhook(string $webhookId): bool + { + $result = $this->listsClient->delete_webhook($webhookId); + if ($result->was_successful()) { + return true; + } + error_log(json_encode($result->response)); + return false; + } + + public function getWebhookToken(): string + { + return md5($this->params->get('campaign_monitor.api_key')); + } + + public function processWebhookBody(string $content): array + { + $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR); + if (!property_exists($content, 'Events') || !is_array($content->Events)) { + throw new \Exception('Invalid webhook payload. Must have Events.'); + } + $memberRepository = $this->em->getRepository(Member::class); + $output = []; + foreach ($content->Events as $event) { + switch($event->Type) { + case 'Update': + $member = $memberRepository->findOneBy([ + 'primaryEmail' => $event->OldEmailAddress + ]); + if (!$member) { + $output[] = [ + 'result' => sprintf( + 'Unable to locate member with %s', + $event->OldEmailAddress + ), + 'payload' => $event + ]; + break; + } + $member->setPrimaryEmail($event->EmailAddress); + $this->sendMemberUpdate($member); + $this->em->persist($member); + $this->em->flush(); + $output[] = [ + 'result' => sprintf( + 'Email for %s updated from %s to %s', + $member, + $event->OldEmailAddress, + $event->EmailAddress + ), + 'payload' => $event + ]; + break; + default: + $output[] = [ + 'result' => 'No action taken.', + 'payload' => $event + ]; + } + } + return $output; + } + + public function sendMemberUpdate(Member $member): void + { + $headers = new Headers(); + $headers->addTextHeader('X-Cmail-GroupName', 'Member Record Update'); + $headers->addTextHeader('X-MC-Tags', 'Member Record Update'); + $message = new TemplatedEmail($headers); + $message + ->to($this->params->get('app.email.to')) + ->from($this->params->get('app.email.from')) + ->subject(sprintf('Member Record Update: %s', $member->getDisplayName())) + ->htmlTemplate('update/email_update.html.twig') + ->context(['member' => $member]) + ; + if ($member->getPrimaryEmail()) { + $message->replyTo($member->getPrimaryEmail()); + } + $this->mailer->send($message); + } + /* Private Methods */ private function buildCustomFieldArray(Member $member): array