Aerendir/aws-ses-monitor-bundle

View on GitHub
src/Service/Monitor.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

declare(strict_types=1);

/*
 * This file is part of the Serendipity HQ Aws Ses Bundle.
 *
 * Copyright (c) Adamo Aerendir Crespi <aerendir@serendipityhq.com>.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace SerendipityHQ\Bundle\AwsSesMonitorBundle\Service;

use Aws\Ses\SesClient;
use Aws\Sns\SnsClient;
use SerendipityHQ\Bundle\AwsSesMonitorBundle\DependencyInjection\Configuration;
use SerendipityHQ\Bundle\AwsSesMonitorBundle\Processor\AwsDataProcessor;
use SerendipityHQ\Bundle\AwsSesMonitorBundle\Util\Console;
use SerendipityHQ\Bundle\AwsSesMonitorBundle\Util\IdentityGuesser;
use Symfony\Component\Console\Output\OutputInterface;

use function Safe\sprintf;

/**
 * Collects the current information from AWS SES and SNS.
 *
 * @internal
 */
final class Monitor
{
    /** @var string */
    private const TRACK = 'track';

    /** @var string */
    private const DKIM = 'dkim';

    /** @var string */
    private const TOPIC = 'topic';

    /** @var string */
    private const IDENTITIES = 'Identities';

    /** @var string */
    private const SUBSCRIPTION_ARN = 'SubscriptionArn';

    /** @var string */
    private const TOPIC_ARN = 'TopicArn';

    private string $env;
    private IdentitiesStore $configuredIdentities;
    private array $liveData;
    private AwsDataProcessor $awsDataProcessor;
    private Console $console;
    private SesClient $sesClient;
    private SnsClient $snsClient;
    private IdentityGuesser $identityGuesser;
    private OutputInterface $sectionTitle;
    private OutputInterface $sectionBody;

    public function __construct(
        string $env,
        AwsDataProcessor $awsDataProcessor,
        Console $console,
        IdentitiesStore $configuredIdentities,
        IdentityGuesser $identityGuesser,
        SesClient $sesClient,
        SnsClient $snsClient
    ) {
        $this->env                  = $env;
        $this->awsDataProcessor     = $awsDataProcessor;
        $this->configuredIdentities = $configuredIdentities;
        $this->identityGuesser      = $identityGuesser;
        $this->console              = $console;
        $this->sesClient            = $sesClient;
        $this->snsClient            = $snsClient;
    }

    public function retrieve(OutputInterface $sectionTitle, OutputInterface $sectionBody, bool $withAccount = false): void
    {
        $this->sectionTitle = $sectionTitle;
        $this->sectionBody  = $sectionBody;

        if ($withAccount) {
            $this->fetchAccountData();
        }

        $this->fetchIdentitiesData();
        $this->fetchTopicsData();
        $this->fetchSubscriptionsData();

        $this->liveData = $this->awsDataProcessor->getProcessedData();

        $this->console->clear($this->sectionTitle);
        $this->console->clear($this->sectionBody);
    }

    public function getAccount(string $attributes = null)
    {
        $return = $this->liveData[AwsDataProcessor::ACCOUNT];

        if (null !== $attributes) {
            return $return[$attributes];
        }

        return $return;
    }

    /**
     * This finds an Identity.
     *
     * If passed an Email Identity and it is not configured, then the method
     * searches for the Domain Identity on which it depends.
     *
     * @return array|bool|int|string
     */
    public function findConfiguredIdentity(string $identity, string $attributes = null)
    {
        $searchingIdentity = $identity;

        if (
            // If this identity isn't explicitly configured
            false === $this->configuredIdentities->identityExists($searchingIdentity) &&
            // and it is an Email Identity
            $this->getIdentityGuesser()->isEmailIdentity($searchingIdentity)
        ) {
            // Find its domain identity
            $parts = $this->getIdentityGuesser()->getEmailParts($searchingIdentity);

            $searchingIdentity = $parts[IdentityGuesser::DOMAIN];
        }

        // If the identity is not explicitly configured (the Email one or its Domain)
        if (false === $this->configuredIdentities->identityExists($searchingIdentity)) {
            $message = $this->getIdentityGuesser()->isEmailIdentity($identity)
                ? sprintf('The Email Identity "%s" nor its Domain identity are configured.', $identity)
                : sprintf('The Domain Identity "%s" is not configured.', $identity);

            throw new \InvalidArgumentException($message);
        }

        return $this->getConfiguredIdentity($searchingIdentity, $attributes);
    }

    /**
     * @return array|bool|int|string
     */
    public function getConfiguredIdentity(string $identity, string $attributes = null)
    {
        return $this->configuredIdentities->getIdentity($identity, $attributes);
    }

    /**
     * @param bool $ignoreEnv if false, returns configured identities allowed on the current environment
     */
    public function getConfiguredIdentitiesList(bool $ignoreEnv = false): array
    {
        if ($ignoreEnv) {
            return [
                'allowed' => $this->configuredIdentities->getIdentitiesList(),
                'skipped' => [],
            ];
        }

        // We have to filter entities based on the environment
        $skipped = [];
        $allowed = [];
        foreach ($this->configuredIdentities->getIdentitiesList() as $identity) {
            // If we are in production and the identity is for production
            ('prod' === $this->env && $this->getIdentityGuesser()->isProductionIdentity($identity)) ||
            // Or if we are on dev (or in test envthe command) and
            // we passed --force (configures also production entities) or
            // we didn't pass the --force option BUT the identity is a test one
            (('dev' === $this->env || 'test' === $this->env) && ($ignoreEnv || $this->getIdentityGuesser()->isTestEmail($identity)))
                // Allow the identity
                ? $allowed[] = $identity
                // Skip the identity
                : $skipped[] = $identity;
        }

        return [
            'allowed' => $allowed,
            'skipped' => $skipped,
        ];
    }

    public function getLiveIdentity(string $identity, string $attributes = null): array
    {
        if (false === $this->liveIdentityExists($identity)) {
            return [];
        }

        $return = $this->liveData[AwsDataProcessor::IDENTITIES][$identity];

        if (null !== $attributes) {
            return $return[$attributes];
        }

        return $return;
    }

    public function getLiveIdentitiesList(): array
    {
        return \array_keys($this->liveData[AwsDataProcessor::IDENTITIES]);
    }

    public function getLiveTopic(string $normalizedTopicName): ?array
    {
        foreach ($this->getLiveTopicsList() as $topicArn) {
            if (false !== \strstr($topicArn, $normalizedTopicName)) {
                return $this->liveData[AwsDataProcessor::TOPICS][$topicArn];
            }
        }

        return null;
    }

    public function getLiveTopicsList(): array
    {
        if (false === isset($this->liveData[AwsDataProcessor::TOPICS])) {
            return [];
        }

        return \array_keys($this->liveData[AwsDataProcessor::TOPICS]);
    }

    public function bouncesTrackingIsEnabled(string $identity): bool
    {
        $config = $this->findConfiguredIdentity($identity, 'bounces');

        return $config[self::TRACK];
    }

    public function bouncesSendingIsForced(string $identity): bool
    {
        $config = $this->findConfiguredIdentity($identity, 'bounces');

        return $config['filter']['force_send'];
    }

    public function complaintsTrackingIsEnabled(string $identity): bool
    {
        $config = $this->findConfiguredIdentity($identity, 'complaints');

        return $config[self::TRACK];
    }

    public function complaintsSendingIsForced(string $identity): bool
    {
        $config = $this->findConfiguredIdentity($identity, 'complaints');

        return $config['filter']['force_send'];
    }

    /**
     * This can apply only to identities.
     */
    public function liveIdentityDkimIsEnabled(string $identity): bool
    {
        return $this->liveIdentityExists($identity) && $this->getLiveIdentity($identity, self::DKIM)['enabled'];
    }

    /**
     * This can apply only to identities.
     */
    public function liveIdentityDkimIsVerified(string $identity): bool
    {
        return
            $this->liveIdentityExists($identity) &&
            'Success' === $this->getLiveIdentity($identity, self::DKIM)['verification_status'];
    }

    public function liveIdentityExists(string $identity): bool
    {
        return isset($this->liveData[AwsDataProcessor::IDENTITIES][$identity]);
    }

    public function liveIdentityIsVerified(string $identity): bool
    {
        return
            $this->liveIdentityExists($identity) &&
            'Success' === $this->getLiveIdentity($identity, 'verification')['status'];
    }

    /**
     * @param string $type Type of notification: bounces, complaints or deliveries
     */
    public function liveNotificationsIncludeHeaders(string $identity, string $type): bool
    {
        return $this->getLiveIdentity($identity, 'notifications')[$type]['include_headers'];
    }

    public function liveTopicExists(string $normalizedTopicName): bool
    {
        foreach ($this->getLiveTopicsList() as $topicArn) {
            if (false !== \strstr($topicArn, $normalizedTopicName)) {
                return true;
            }
        }

        return false;
    }

    public function dkimEnabledIsInSync(string $identity): bool
    {
        return $this->getConfiguredIdentity($identity, self::DKIM) === ($this->getLiveIdentity($identity, self::DKIM)['enabled'] ?? null);
    }

    public function fromDomainIsInSync(string $identity): bool
    {
        return
            $this->getConfiguredIdentity($identity, 'on_mx_failure') === ($this->getLiveIdentity($identity, 'mail_from')['on_mx_failure'] ?? null) &&
            $this->getConfiguredIdentity($identity, 'from_domain')   === ($this->getLiveIdentity($identity, 'mail_from')[self::DOMAIN]    ?? null);
    }

    public function fromDomainCanBeSynched(string $identity): bool
    {
        // If is a domain identity and is verified
        if (false === $this->identityGuesser->isEmailIdentity($identity) && $this->liveIdentityIsVerified($identity)) {
            // Then can be synched
            return true;
        }

        if ($this->identityGuesser->isEmailIdentity($identity)) {
            // This is an email identity: check its domain identity
            $parts    = $this->identityGuesser->getEmailParts($identity);
            $identity = $parts[self::DOMAIN];
        }

        return $this->liveIdentityIsVerified($identity);
    }

    /**
     * @param string $type Bounces, complaints or deliveries
     */
    public function requiresTopicConfiguration(string $identity, string $type): bool
    {
        $topicConfig = $this->getConfiguredIdentity($identity, $type);

        return $topicConfig[self::TRACK] && Configuration::USE_DOMAIN !== $topicConfig[self::TOPIC];
    }

    public function identityRequiresTopicSubscription(string $identity, string $notificationType): bool
    {
        $identityNotifications = $this->getLiveIdentity($identity, 'notifications');

        // If the notification SNS topic is not configured
        if (false === isset($identityNotifications[$notificationType]) || false === isset($identityNotifications[$notificationType][self::TOPIC])) {
            return true;
        }

        // If there are no subscriptions, then for sure we need to subscribe the identity
        if (empty($this->liveData[AwsDataProcessor::SUBSCRIPTIONS])) {
            return true;
        }

        // Check each subscription...
        foreach ($this->liveData[AwsDataProcessor::SUBSCRIPTIONS] as $subscription) {
            // If we find at least one subscription with the same topic arn
            if ($subscription['topic_arn'] === $identityNotifications[$notificationType][self::TOPIC]) {
                return false;
            }
        }

        // If all the previous conditions aren't met, return true as we didn't found a subscription that links the topic of the Identity with
        return true;
    }

    public function subscriptionEndpointIsInSynch(string $topicArn, string $currentEndpoint): bool
    {
        $subscription = $this->getTopicSubscriptions($topicArn);
        if (null === $subscription) {
            return false;
        }

        return $subscription['endpoint'] === $currentEndpoint;
    }

    public function getTopicSubscriptions(string $topicArn): ?array
    {
        if (false === isset($this->liveData[AwsDataProcessor::SUBSCRIPTIONS])) {
            return null;
        }

        foreach ($this->liveData[AwsDataProcessor::SUBSCRIPTIONS] as $subscription) {
            if ($subscription['topic_arn'] === $topicArn) {
                return $subscription;
            }
        }

        return null;
    }

    public function getIdentityGuesser(): IdentityGuesser
    {
        return $this->identityGuesser;
    }

    /**
     * Fetches the Account's data.
     */
    private function fetchAccountData(): void
    {
        $this->console->overwrite('Retrieving information of Account:', $this->sectionTitle);
        $this->console->overwrite('   Retrieving Account sending status...', $this->sectionBody);
        $accountSendingEnabled = $this->sesClient->getAccountSendingEnabled();
        $this->awsDataProcessor->processAccountSendingEnabled($accountSendingEnabled);

        $this->console->overwrite('   Retrieving Account send quota...', $this->sectionBody);
        $accountSendQuota = $this->sesClient->getSendQuota();
        $this->awsDataProcessor->processAccountSendQuota($accountSendQuota);

        $this->console->overwrite('   Retrieving Account send statistics...', $this->sectionBody);
        $accountSendStatistics = $this->sesClient->getSendStatistics();
        $this->awsDataProcessor->processAccountSendStatistics($accountSendStatistics);

        $this->console->clear($this->sectionBody);
        $this->console->clear($this->sectionTitle);
    }

    /**
     * Fetches the data of configured Identities.
     */
    private function fetchIdentitiesData(): void
    {
        $this->console->overwrite('Retrieving information of Identities:', $this->sectionTitle);
        $this->console->overwrite('   Retrieving DKIM attributes...', $this->sectionBody);
        $identitiesDkimAttributes = $this->sesClient->getIdentityDkimAttributes([self::IDENTITIES => $this->configuredIdentities->getIdentitiesList()]);
        $this->awsDataProcessor->processIdentitiesDkimAttributes($identitiesDkimAttributes);

        // This operation is throttled at one request per second and can only get custom MAIL FROM attributes for up to 100 identities at a time.
        // https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-email-2010-12-01.html#getidentitymailfromdomainattributes
        $this->console->overwrite('   Retrieving MAIL FROM domain attributes...', $this->sectionBody);
        $identitiesMailFromDomainAttributes = $this->sesClient->getIdentityMailFromDomainAttributes([self::IDENTITIES => $this->configuredIdentities->getIdentitiesList()]);
        $this->awsDataProcessor->processIdentitiesMailFromDomainAttributes($identitiesMailFromDomainAttributes);

        // This operation is throttled at one request per second and can only get custom MAIL FROM attributes for up to 100 identities at a time.
        // https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-email-2010-12-01.html#getidentitynotificationattributes
        $this->console->overwrite('   Retrieving notification attributes...', $this->sectionBody);
        $identityNotificationAttributes = $this->sesClient->getIdentityNotificationAttributes([self::IDENTITIES => $this->configuredIdentities->getIdentitiesList()]);
        $this->awsDataProcessor->processIdentitiesNotificationAttributes($identityNotificationAttributes);

        // Given a list of identities (email addresses and/or domains), returns the verification
        // status and (for domain identities) the verification token for each identity.
        //
        // The verification status of an email address is "Pending" until the email address owner clicks the
        // link within the verification email that Amazon SES sent to that address. If the email address
        // owner clicks the link within 24 hours, the verification status of the email address changes to "Success".
        // If the link is not clicked within 24 hours, the verification status changes to "Failed." In that case,
        // if you still want to verify the email address, you must restart the verification process from the beginning.
        //
        // For domain identities, the domain's verification status is "Pending" as Amazon SES searches for the required
        // TXT record in the DNS settings of the domain. When Amazon SES detects the record, the domain's verification
        // status changes to "Success". If Amazon SES is unable to detect the record within 72 hours, the domain's
        // verification status changes to "Failed." In that case, if you still want to verify the domain, you must
        // restart the verification process from the beginning.
        //
        // This operation is throttled at one request per second and can only get verification attributes for up to 100 identities at a time.
        // https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-email-2010-12-01.html#getidentityverificationattributes
        $this->console->overwrite('   Retrieving verification attributes...', $this->sectionBody);
        $identityVerificationAttributes = $this->sesClient->getIdentityVerificationAttributes([self::IDENTITIES => $this->configuredIdentities->getIdentitiesList()]);
        $this->awsDataProcessor->processIdentitiesVerificationAttributes($identityVerificationAttributes);

        $this->console->clear($this->sectionBody);
        $this->console->clear($this->sectionTitle);
    }

    /**
     * Fetches data of subscriptions.
     */
    private function fetchSubscriptionsData(): void
    {
        // Returns a list of the requester's subscriptions. Each call returns a limited list of subscriptions, up to 100.
        // If there are more subscriptions, a NextToken is also returned. Use the NextToken parameter in a new
        // ListSubscriptions call to get further results.
        //
        // @todo This result has the token: use it to implement the cycling over paginated results
        //
        // This action is throttled at 30 transactions per second (TPS).
        // https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#listsubscriptions
        $this->console->overwrite('Retrieving Subscriptions...', $this->sectionTitle);
        $subscriptions = $this->snsClient->listSubscriptions();
        $this->awsDataProcessor->processSubscriptions($subscriptions);

        foreach ($subscriptions->get('Subscriptions') as $subscription) {
            if ('PendingConfirmation' === $subscription[self::SUBSCRIPTION_ARN]) {
                continue;
            }

            $this->console->overwrite(sprintf('   Retrieving attributes for subscription <comment>%s</comment>', $subscription[self::SUBSCRIPTION_ARN]), $this->sectionBody);

            try {
                $subscriptionAttributes = $this->snsClient->getSubscriptionAttributes([self::SUBSCRIPTION_ARN => $subscription[self::SUBSCRIPTION_ARN]]);
                $this->awsDataProcessor->processSubscriptionAttributes($subscriptionAttributes);
            }
            // @codeCoverageIgnoreStart
            catch (\Throwable $throwable) {
                // Do nothing for the moment
                // This throws an error when the subscription doesn't exist.
                // The problem is that all the subscriptions are returned by the previous call to list subscriptions.
                // So, I have a call that returns me some subscriptions that don't exist. And this is a problem.
            }

            // @codeCoverageIgnoreEnd

            $this->console->clear($this->sectionBody);
        }

        $this->console->clear($this->sectionBody);
        $this->console->clear($this->sectionTitle);
    }

    /**
     * Fetches data of topics.
     */
    private function fetchTopicsData(): void
    {
        // Returns a list of the requester's topics. Each call returns a limited list of topics, up to 100.
        // If there are more topics, a NextToken is also returned. Use the NextToken parameter in a new
        // ListTopics call to get further results.
        //
        // This action is throttled at 30 transactions per second (TPS).
        // https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sns-2010-03-31.html#listtopics
        $this->console->overwrite('Retrieving Topics...', $this->sectionTitle);
        $topics = $this->snsClient->listTopics();
        $this->awsDataProcessor->processTopics($topics);

        foreach ($topics->get('Topics') as $topic) {
            $this->console->overwrite(sprintf('   Retrieving attributes for topic <comment>%s</comment>', $topic[self::TOPIC_ARN]), $this->sectionBody);
            // try {
            $topicAttributes = $this->snsClient->getTopicAttributes([self::TOPIC_ARN => $topic[self::TOPIC_ARN]]);
            $this->awsDataProcessor->processTopicAttributes($topicAttributes);
            // } catch (\Throwable $e) {
            // Do nothing for the moment
            // This throws an error when the subscription doesn't exist.
            // The problem is that all the subscriptions are returned by the previous call to list subscriptions.
            // So, I have a call that returns me some subscriptions that don't exist. And this is a problem.
            // }
        }

        $this->console->clear($this->sectionBody);
        $this->console->clear($this->sectionTitle);
    }
}