diff --git a/composer.json b/composer.json index 1d56a5406..eb180dfc3 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ ], "require": { "php": ">=8.2", + "aws/aws-sdk-php": "^3.281.6", "composer/installers": "^1.12", "cweagans/composer-patches": "^1.7", "drupal/admin_denied": "^2.0", diff --git a/composer.lock b/composer.lock index 9ee03ee44..5235a6b17 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "706c99bd199323e0e46bd92e36ec0f88", + "content-hash": "13919cd5f2cf18540f5b8c0ace147a70", "packages": [ { "name": "asm89/stack-cors", @@ -62,6 +62,155 @@ }, "time": "2022-01-18T09:12:03+00:00" }, + { + "name": "aws/aws-crt-php", + "version": "v1.2.2", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "2f1dc7b7eda080498be96a4a6d683a41583030e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/2f1dc7b7eda080498be96a4a6d683a41583030e9", + "reference": "2f1dc7b7eda080498be96a4a6d683a41583030e9", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.2" + }, + "time": "2023-07-20T16:49:55+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.281.6", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "b83c543a9ff07fc1d9f11766ee77fc7f4deed2b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b83c543a9ff07fc1d9f11766ee77fc7f4deed2b9", + "reference": "b83c543a9ff07fc1d9f11766ee77fc7f4deed2b9", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.0.4", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "guzzlehttp/promises": "^1.4.0 || ^2.0", + "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", + "mtdowling/jmespath.php": "^2.6", + "php": ">=7.2.5", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^1.10.22", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-pcntl": "*", + "ext-sockets": "*", + "nette/neon": "^2.3", + "paragonie/random_compat": ">= 2", + "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", + "psr/cache": "^1.0", + "psr/simple-cache": "^1.0", + "sebastian/comparator": "^1.2.3 || ^4.0", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.281.6" + }, + "time": "2023-09-13T18:07:28+00:00" + }, { "name": "behat/mink", "version": "v1.10.0", @@ -7225,6 +7374,72 @@ }, "time": "2022-02-23T02:02:42+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/bbb69a935c2cbb0c03d7f481a238027430f6440b", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.7.0" + }, + "time": "2023-08-25T10:54:48+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.11.1", @@ -15508,5 +15723,5 @@ "php": ">=8.2" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/config/reliefweb_subscriptions.settings.yml b/config/reliefweb_subscriptions.settings.yml new file mode 100644 index 000000000..430515f83 --- /dev/null +++ b/config/reliefweb_subscriptions.settings.yml @@ -0,0 +1,10 @@ +method: smtp +aws_ses_api_version: latest +aws_ses_api_region: null +aws_ses_api_endpoint: null +aws_ses_api_bulk_batch_size: 50 +aws_ses_api_key: null +aws_ses_api_secret: null +aws_ses_api_token: null +aws_ses_api_scheme: null +aws_ses_api_identity: null diff --git a/html/modules/custom/reliefweb_subscriptions/config/install/reliefweb_subscriptions.settings.yml b/html/modules/custom/reliefweb_subscriptions/config/install/reliefweb_subscriptions.settings.yml new file mode 100644 index 000000000..ea548e969 --- /dev/null +++ b/html/modules/custom/reliefweb_subscriptions/config/install/reliefweb_subscriptions.settings.yml @@ -0,0 +1,20 @@ +# How to send the emails: smtp or AWS SES API (aws_ses_api_bulk). +method: smtp +# Version of the AWS SES API. +aws_ses_api_version: latest +# Region to use for the AWS SES API. +aws_ses_api_region: +# Endpoint of the AWS SES API, leave empty to use the default endpoint. +aws_ses_api_endpoint: +# Scheme of the AWS SES API endpoint, leave empty to use the default scheme. +aws_ses_api_scheme: +# Maximum number of recipients when using the bulk endpoint. +aws_ses_api_bulk_batch_size: 50 +# AWS SES API key. +aws_ses_api_key: +# AWS SES API secret. +aws_ses_api_secret: +# AWS SES API token. +aws_ses_api_token: +# AWS SES API identity. +aws_ses_api_identity: diff --git a/html/modules/custom/reliefweb_subscriptions/config/schema/reliefweb_subscriptions.schema.yml b/html/modules/custom/reliefweb_subscriptions/config/schema/reliefweb_subscriptions.schema.yml new file mode 100644 index 000000000..f14f38bea --- /dev/null +++ b/html/modules/custom/reliefweb_subscriptions/config/schema/reliefweb_subscriptions.schema.yml @@ -0,0 +1,34 @@ +reliefweb_subscriptions.settings: + type: config_object + label: 'Reliefweb Subscriptions settings' + mapping: + method: + type: string + label: 'How to send the emails: smtp or AWS SES API (aws_ses_api_bulk)' + aws_ses_api_version: + type: string + label: 'Version of the AWS SES API' + aws_ses_api_region: + type: string + label: 'Region to use for the AWS SES API' + aws_ses_api_endpoint: + type: string + label: 'Endpoint of the AWS SES API, leave empty to use the default endpoint' + aws_ses_api_schema: + type: string + label: 'Scheme of the AWS SES API endpoint, leave empty to use the default scheme' + aws_ses_api_bulk_batch_size: + type: integer + label: 'Maximum number of recipients when using the bulk endpoint' + aws_ses_api_key: + type: string + label: 'AWS SES API key' + aws_ses_api_secret: + type: string + label: 'AWS SES API secret' + aws_ses_api_token: + type: string + label: 'AWS SES API token' + aws_ses_api_identity: + type: string + label: 'AWS SES API identity' diff --git a/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.module b/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.module index 6bc5cd8cd..b08364f3b 100644 --- a/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.module +++ b/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.module @@ -26,6 +26,7 @@ function reliefweb_subscriptions_subscriptions() { 'id' => 'headlines', 'name' => t('ReliefWeb Headlines'), 'description' => t('ReliefWeb Headlines (daily)'), + 'aws_ses_template_name' => 'reliefweb_subscriptions_headlines', // Sengrid category. 'category' => 'Headlines', 'type' => 'scheduled', @@ -66,6 +67,7 @@ function reliefweb_subscriptions_subscriptions() { 'id' => 'appeals', 'name' => t('All Appeals'), 'description' => t('Latest Appeals (weekly)'), + 'aws_ses_template_name' => 'reliefweb_subscriptions_appeals', // Sengrid cateogry. 'category' => 'Appeals', 'type' => 'scheduled', @@ -104,6 +106,7 @@ function reliefweb_subscriptions_subscriptions() { 'id' => 'jobs', 'name' => t('All Jobs'), 'description' => t('All Jobs (bi-weekly: Monday, Thursday)'), + 'aws_ses_template_name' => 'reliefweb_subscriptions_jobs', 'group' => 'global', // Sengrid cateogry. 'category' => 'Jobs digest', @@ -138,6 +141,7 @@ function reliefweb_subscriptions_subscriptions() { 'id' => 'training', 'name' => t('All Training Programs'), 'description' => t('All Training Programs (weekly: Wednesday)'), + 'aws_ses_template_name' => 'reliefweb_subscriptions_training', 'group' => 'global', // Sengrid cateogry. 'category' => 'Training programs digest', @@ -174,6 +178,7 @@ function reliefweb_subscriptions_subscriptions() { 'id' => 'disaster', 'name' => t('New alert or ongoing disaster'), 'description' => t('New alert or ongoing disaster (when published)'), + 'aws_ses_template_name' => 'reliefweb_subscriptions_disasters', 'group' => 'global', // Sengrid cateogry. 'category' => 'New Disasters', @@ -208,6 +213,7 @@ function reliefweb_subscriptions_subscriptions() { 'id' => 'ocha_sitrep', 'name' => t('New OCHA situation report'), 'description' => t('New OCHA situation report'), + 'aws_ses_template_name' => 'reliefweb_subscriptions_ocha_sitrep', 'group' => 'global', // Sengrid cateogry. 'category' => 'OCHA Sitreps', @@ -262,6 +268,7 @@ function reliefweb_subscriptions_subscriptions() { 'id' => $sid, 'name' => t('Updates on @country', ['@country' => $name]), 'description' => t('Updates on @country (daily)', ['@country' => $name]), + 'aws_ses_template_name' => 'reliefweb_subscriptions_' . $sid, 'group' => 'country_updates', // This is just for convenience for the `My subscriptions` page and the // template preprocess function. diff --git a/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.routing.yml b/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.routing.yml index 5ed5e96b5..0a95bb73f 100644 --- a/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.routing.yml +++ b/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.routing.yml @@ -18,6 +18,14 @@ reliefweb_subscriptions.subscription_form.current_user: requirements: _user_is_logged_in: 'TRUE' reliefweb_subscriptions.unsubscribe: + path: '/notifications/unsubscribe/user/{user}/{timestamp}/{signature}' + defaults: + _controller: '\Drupal\reliefweb_subscriptions\Controller\UnsubscribeController::unsubscribe' + _title: 'Unsubscribe' + requirements: + _access: 'TRUE' + user: \d+ +reliefweb_subscriptions.unsubscribe.parameters: path: '/notifications/unsubscribe/user/{user}' defaults: _controller: '\Drupal\reliefweb_subscriptions\Controller\UnsubscribeController::unsubscribe' diff --git a/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.services.yml b/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.services.yml index a77867b44..fced4b1ba 100644 --- a/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.services.yml +++ b/html/modules/custom/reliefweb_subscriptions/reliefweb_subscriptions.services.yml @@ -1,4 +1,8 @@ services: reliefweb_subscriptions.mailer: class: \Drupal\reliefweb_subscriptions\ReliefwebSubscriptionsMailer - arguments: ['@config.factory', '@database', '@entity_field.manager', '@entity.repository', '@entity_type.manager', '@state', '@datetime.time', '@reliefweb_api.client', '@private_key', '@renderer', '@plugin.manager.mail', '@language.default', '@logger.factory', '@theme.initialization', '@theme.manager', '@theme_handler'] + arguments: ['@config.factory', '@database', '@entity_field.manager', '@entity.repository', '@entity_type.manager', '@state', '@datetime.time', '@reliefweb_api.client', '@private_key', '@renderer', '@plugin.manager.mail', '@language.default', '@logger.factory', '@theme.initialization', '@theme.manager', '@theme_handler', '@reliefweb_subscriptions.aws.ses.client'] + + reliefweb_subscriptions.aws.ses.client: + class: \Drupal\reliefweb_subscriptions\Services\AwsSesClient + arguments: ['@config.factory', '@logger.factory'] diff --git a/html/modules/custom/reliefweb_subscriptions/src/Command/ReliefwebSubscriptionsSendCommand.php b/html/modules/custom/reliefweb_subscriptions/src/Command/ReliefwebSubscriptionsSendCommand.php index a94186df5..478a25fad 100644 --- a/html/modules/custom/reliefweb_subscriptions/src/Command/ReliefwebSubscriptionsSendCommand.php +++ b/html/modules/custom/reliefweb_subscriptions/src/Command/ReliefwebSubscriptionsSendCommand.php @@ -60,9 +60,15 @@ public function __construct( * * @param int $limit * Max number of items to send. + * @param array $options + * Additional options for the command. * * @command reliefweb_subscriptions:send * + * @option from From email address. + * + * @default $options [] + * * @usage reliefweb_subscriptions:send * Send emails. * @@ -70,7 +76,9 @@ public function __construct( * * @validate-module-enabled reliefweb_subscriptions */ - public function send($limit = 50) { + public function send($limit = 50, array $options = [ + 'from' => '', + ]) { // Get queued notifications, older first. // Triggered notifications have priority. $query = $this->database->select('reliefweb_subscriptions_queue', 'q'); @@ -82,7 +90,7 @@ public function send($limit = 50) { // Send the notifications. $notifications = $query->execute()?->fetchAllAssoc('eid'); - $this->mailer->send($notifications); + $this->mailer->send($notifications, $options['from'] ?? ''); // Remove the processed notifications from the queue. if (!empty($notifications)) { diff --git a/html/modules/custom/reliefweb_subscriptions/src/Controller/UnsubscribeController.php b/html/modules/custom/reliefweb_subscriptions/src/Controller/UnsubscribeController.php index 4a6b0c9ad..e2115cae7 100644 --- a/html/modules/custom/reliefweb_subscriptions/src/Controller/UnsubscribeController.php +++ b/html/modules/custom/reliefweb_subscriptions/src/Controller/UnsubscribeController.php @@ -50,10 +50,10 @@ public static function create(ContainerInterface $container) { /** * Unsubscribe a user. */ - public function unsubscribe(AccountInterface $user) { + public function unsubscribe(AccountInterface $user, $timestamp = NULL, $signature = NULL) { if ($this->account->isAnonymous()) { - $timestamp = $this->request->get('timestamp'); - $signature = $this->request->get('signature'); + $timestamp = $timestamp ?? $this->request->get('timestamp'); + $signature = $signature ?? $this->request->get('signature'); if (empty($timestamp) || empty($signature)) { throw new AccessDeniedHttpException(); } diff --git a/html/modules/custom/reliefweb_subscriptions/src/ReliefwebSubscriptionsMailer.php b/html/modules/custom/reliefweb_subscriptions/src/ReliefwebSubscriptionsMailer.php index 2bfe96f9e..31247121f 100644 --- a/html/modules/custom/reliefweb_subscriptions/src/ReliefwebSubscriptionsMailer.php +++ b/html/modules/custom/reliefweb_subscriptions/src/ReliefwebSubscriptionsMailer.php @@ -27,7 +27,9 @@ use Drupal\reliefweb_api\Services\ReliefWebApiClient; use Drupal\reliefweb_entities\Entity\Disaster; use Drupal\reliefweb_entities\Entity\Report; +use Drupal\reliefweb_subscriptions\Services\AwsSesClient; use Drupal\reliefweb_utility\Helpers\HtmlSummarizer; +use Drupal\reliefweb_utility\Helpers\MailHelper; /** * Subscription mailer. @@ -90,6 +92,13 @@ class ReliefwebSubscriptionsMailer { */ protected $reliefwebApiClient; + /** + * AWS SES Client. + * + * @var \Drupal\reliefweb_subscriptions\Services\AwsSesClient + */ + protected $awsSesClient; + /** * Private key. * @@ -146,6 +155,27 @@ class ReliefwebSubscriptionsMailer { */ protected $themeHandler; + /** + * From address for the emails. + * + * @var string + */ + protected $fromAddress; + + /** + * Number of emails that can be sent per seconds. + * + * @var int + */ + protected $sendRate; + + /** + * Number of emails that can be sent in a bulk email. + * + * @var int + */ + protected $batchSize; + /** * Store the link tracking state for subscriptions. * @@ -164,15 +194,16 @@ public function __construct( EntityTypeManagerInterface $entity_type_manager, StateInterface $state, TimeInterface $time, - ReliefWebApiClient $reliefwebApiClient, - PrivateKey $privateKey, + ReliefWebApiClient $reliefweb_api_client, + PrivateKey $private_key, RendererInterface $renderer, - MailManagerInterface $mailManager, - LanguageDefault $languageDefault, - LoggerChannelFactoryInterface $loggerFactory, - ThemeInitialization $themeInitialization, - ThemeManagerInterface $themeManager, - ThemeHandlerInterface $themeHandler + MailManagerInterface $mail_manager, + LanguageDefault $language_default, + LoggerChannelFactoryInterface $logger_factory, + ThemeInitialization $theme_initialization, + ThemeManagerInterface $theme_manager, + ThemeHandlerInterface $theme_handler, + awsSesClient $aws_ses_client, ) { $this->configFactory = $config_factory; $this->database = $database; @@ -181,15 +212,16 @@ public function __construct( $this->entityTypeManager = $entity_type_manager; $this->state = $state; $this->time = $time; - $this->reliefwebApiClient = $reliefwebApiClient; - $this->privateKey = $privateKey; + $this->reliefwebApiClient = $reliefweb_api_client; + $this->privateKey = $private_key; $this->renderer = $renderer; - $this->mailManager = $mailManager; - $this->languageDefault = $languageDefault; - $this->logger = $loggerFactory->get('reliefweb_subscriptions'); - $this->themeInitialization = $themeInitialization; - $this->themeManager = $themeManager; - $this->themeHandler = $themeHandler; + $this->mailManager = $mail_manager; + $this->languageDefault = $language_default; + $this->logger = $logger_factory->get('reliefweb_subscriptions'); + $this->themeInitialization = $theme_initialization; + $this->themeManager = $theme_manager; + $this->themeHandler = $theme_handler; + $this->awsSesClient = $aws_ses_client; } /** @@ -209,8 +241,13 @@ protected function config($name) { /** * Send notifications. + * + * @param array $notifications + * List of notifications to send. + * @param string $from + * From address override. If not defined, use the system email address. */ - public function send($notifications) { + public function send(array $notifications, $from = '') { if (empty($notifications)) { $this->logger->info('No queued notifications.'); return; @@ -236,10 +273,10 @@ public function send($notifications) { $subscription = $subscriptions[$notification->sid]; if ($subscription['type'] === 'scheduled') { - $sent = $this->sendScheduledNotification($notification, $subscription); + $sent = $this->sendScheduledNotification($notification, $subscription, $from); } else { - $sent = $this->sendTriggeredNotification($notification, $subscription); + $sent = $this->sendTriggeredNotification($notification, $subscription, $from); } if ($sent) { @@ -336,17 +373,19 @@ public function queue($sid, array $options = [ * Notification information. * @param array $subscription * Subscription information. + * @param string $from + * From address override. If not defined, use the system email address. * * @return bool * TRUE if emails were sent. */ - public function sendScheduledNotification(object $notification, array $subscription) { + public function sendScheduledNotification(object $notification, array $subscription, $from = '') { $data = $this->getScheduledNotificationData($notification, $subscription); if (empty($data)) { return FALSE; } - return $this->generateEmail($subscription, $data); + return $this->generateEmail($subscription, $data, $from); } /** @@ -356,17 +395,19 @@ public function sendScheduledNotification(object $notification, array $subscript * Notification information. * @param array $subscription * Subscription information. + * @param string $from + * From address override. If not defined, use the system email address. * * @return bool * TRUE if emails were sent. */ - public function sendTriggeredNotification(object $notification, array $subscription) { + public function sendTriggeredNotification(object $notification, array $subscription, $from = '') { $data = $this->getTriggeredNotificationData($notification, $subscription); if (empty($data)) { return FALSE; } - return $this->generateEmail($subscription, $data); + return $this->generateEmail($subscription, $data, $from); } /** @@ -423,6 +464,28 @@ protected function getScheduledNotificationData(object $notification, array $sub return $items; } + /** + * Get the from address. + * + * @return string + * From address. + */ + protected function getFromAddress() { + // Retrieve the From email address. + if (!isset($this->fromAddress)) { + $from = $this->config('system.site')->get('mail') ?? ini_get('sendmail_from'); + // Format the from to include ReliefWeb if not already. + if (strpos($from, '<') === FALSE) { + $from = $this->formatString('@sitename <@sitemail>', [ + '@sitename' => $this->config('system.site')->get('name') ?? 'ReliefWeb', + '@sitemail' => $from, + ]); + } + $this->fromAddress = (string) $from; + } + return $this->fromAddress; + } + /** * Generate the notification email content (subject, body, headers). * @@ -430,6 +493,8 @@ protected function getScheduledNotificationData(object $notification, array $sub * Subscription. * @param array $data * API data to use in the templates. + * @param string $from + * From address override. If not defined, use the system email address. * * @return bool * TRUE if emails were sent. @@ -437,58 +502,115 @@ protected function getScheduledNotificationData(object $notification, array $sub * @todo use templates for the text version instead of relying on * drupal_html_to_text(). That would mean changing the ExtendedMailSystem. */ - protected function generateEmail(array $subscription, array $data) { - static $from; - static $language; - static $batch_size; + protected function generateEmail(array $subscription, array $data, $from = '') { + $sid = $subscription['id']; + $subscription_name = $subscription['name']; - if (!isset($from)) { - $from = $this->config('system.site')->get('mail') ?? ini_get('sendmail_from'); - // Format the from to include ReliefWeb if not already. - if (strpos($from, '<') === FALSE) { - $from = $this->formatString('@sitename <@sitemail>', [ - '@sitename' => $this->config('system.site')->get('name') ?? 'ReliefWeb', - '@sitemail' => $from, - ]); - } - $language = $this->languageDefault->get()->getId(); - // Number of emails to send by second. - $batch_size = $this->state->get('reliefweb_subscriptions_mail_batch_size', 40); + // Get the subscribers. + $subscribers = $this->getSubscribers($sid); + if (empty($subscribers)) { + $this->logger->info('No subscribers found for {name} subscription.', [ + 'name' => $subscription_name, + ]); + return FALSE; } - $sid = $subscription['id']; - // Get the mail subject. $subject = $this->generateEmailSubject($subscription, $data); if (empty($subject)) { $this->logger->error('Unable to generate subject for {name} subscription.', [ - 'name' => $subscription['name'], + 'name' => $subscription_name, ]); return FALSE; } // Generate the HTML and text content. - $body = $this->generateEmailContent($subscription, $data); - if (empty($body)) { - $this->logger->error('Unable to generate body for {name} subscription.', [ - 'name' => $subscription['name'], + $html = $this->generateEmailContent($subscription, $data); + if (empty($html)) { + $this->logger->error('Unable to generate HTML body for {name} subscription.', [ + 'name' => $subscription_name, ]); return FALSE; } - // Get the subscribers. - $subscribers = $this->getSubscribers($sid); - if (empty($subscribers)) { - $this->logger->info('No subscribers found for {name} subscription.', [ - 'name' => $subscription['name'], - ]); - return FALSE; + switch ($this->config('reliefweb_subscriptions.settings')->get('method')) { + case 'smtp': + return $this->sendViaSmtp($subscription, $subject, $html, $subscribers, $from); + + case 'aws_ses_api_bulk': + return $this->sendViaAwsSesApiBulk($subscription, $subject, $html, $subscribers, $from); + + default: + $this->logger->error('Invalid send mode.'); } - $this->logger->info('Sending {subject} notification to {subscribers} subscribers.', [ - 'subject' => $subject, - 'subscribers' => count($subscribers), - ]); + return FALSE; + } + + /** + * Optimize bulk email template. + * + * This tries to use placeholders for elements that repeated many times in + * order to reduce the size of the email template as there is a limit of + * 500 KB and ~ 250KB for replacements. + * + * @param string $html + * HTML version of the body of the email. + * + * @return array + * The transformed HTML content and the Key/value array of placeholder + * replacements.. + */ + protected function optimizeEmailTemplate($html) { + $replacements = []; + + // Replace commonly repeating content with placeholders to reduce the size + // of the email content. + $html = preg_replace_callback('#(style="[^"]+"|https?://[^/]+/)#', function ($match) use (&$replacements) { + $key = $match[0]; + if (!isset($replacements[$key])) { + $replacement = 'r' . count($replacements); + $replacements[$key] = $replacement; + } + else { + $replacement = $replacements[$key]; + } + return '{{' . $replacement . '}}'; + }, $html); + + // Set the placeholders as keys. + $replacements = array_flip($replacements); + + // Replace the unsubscribe link with a placeholder. + $html = str_replace('@unsubscribe', '{{unsubscribe}}', $html); + + return [$html, $replacements]; + } + + /** + * Send the emails via SMTP. + * + * @param array $subscription + * Subscription. + * @param string $subject + * Email subject. + * @param string|\Drupal\Component\Render\FormattableMarkup $html + * Email content. + * @param array $subscribers + * Subscribers. + * @param string $from + * From address override. If not defined, use the system email address. + * + * @return bool + * False if the sending failed. + */ + protected function sendViaSmtp(array $subscription, $subject, $html, array $subscribers, $from = '') { + $sid = $subscription['id']; + $from = $from ?: $this->getFromAddress(); + $language = $this->languageDefault->get()->getId(); + + // Number of emails to send by second. + $batch_size = $this->state->get('reliefweb_subscriptions_mail_batch_size', 40); // Probably only used to categorise emails on SendGrid admin. $category = $subscription['category']; @@ -501,6 +623,11 @@ protected function generateEmail(array $subscription, array $data) { 'absolute' => TRUE, ]))->toString(); + $this->logger->info('Sending {subject} notification to {subscribers} subscribers.', [ + 'subject' => $subject, + 'subscribers' => count($subscribers), + ]); + // Batch the subscribe list, so we can throttle if it looks like // we will go over our allowed rate limit. foreach (array_chunk($subscribers, $batch_size) as $batch) { @@ -514,8 +641,8 @@ protected function generateEmail(array $subscription, array $data) { // Generate the individual unsubscribe link. $unsubscribe = $this->generateUnsubscribeLink($subscriber->uid, $sid); - // Update the body with the unique ubsubscribe link. - $mail_body = new FormattableMarkup($body, [ + // Update the HTML body with the unique unsubscribe link. + $mail_body = new FormattableMarkup($html, [ '@feedback' => $feedback, '@unsubscribe' => $unsubscribe, ]); @@ -545,6 +672,169 @@ protected function generateEmail(array $subscription, array $data) { return TRUE; } + /** + * Send the emails via the AWS SES API. + * + * @param array $subscription + * Subscription. + * @param string $subject + * Email subject. + * @param string|\Drupal\Component\Render\FormattableMarkup $html + * Email content. + * @param array $subscribers + * Subscribers. + * @param string $from + * From address override. If not defined, use the system email address. + * + * @return bool + * False if the sending failed. + */ + protected function sendViaAwsSesApiBulk(array $subscription, $subject, $html, array $subscribers, $from = '') { + $sid = $subscription['id']; + $subscription_name = $subscription['name']; + $template_name = $subscription['aws_ses_template_name']; + $from = $from ?: $this->getFromAddress(); + + // Retrieve the send rate. + try { + $send_rate = $this->awsSesClient->getSendRate(); + } + catch (\Exception $exception) { + $this->logger->error('Unable to retrieve the max send rate.'); + return FALSE; + } + + // Retrieve the number of emails to send for each bulk request. + $bulk_batch_size = $this->config('reliefweb_subscriptions.settings')->get('aws_ses_api_bulk_batch_size') ?? 1; + $bulk_batch_size = min($send_rate, $bulk_batch_size); + + // Link to the feedback page. + $feedback = Url::fromUserInput('/contact', $this->addLinkTrackingParameters($sid, [ + 'absolute' => TRUE, + ]))->toString(); + // Replace the feedback placeholder. + $html = new FormattableMarkup($html, [ + '@feedback' => $feedback, + ]); + + // Get the full HTML body. + $build = [ + '#theme' => 'mimemail_message', + '#module' => 'reliefweb_subscriptions', + '#key' => 'notifications', + '#subject' => $subject, + '#body' => $html, + ]; + $html = $this->renderer->renderPlain($build); + + // Optimize the HTML body of the email. + [$html, $replacements] = $this->optimizeEmailTemplate($html); + + // Generate the text version of the email. + $text = MailHelper::getPlainText($html); + + // Create the template. + try { + $this->awsSesClient->createTemplate( + $template_name, + $subject, + $html, + $text + ); + } + catch (\Exception $exception) { + $this->logger->error('Unable to create template for {name} subscription: {error}', [ + 'name' => $subscription_name, + 'error' => $exception->getMessage(), + ]); + return FALSE; + } + + $this->logger->info('Sending {subject} notification to {subscribers} subscribers.', [ + 'subject' => $subject, + 'subscribers' => count($subscribers), + ]); + + // Batch the subscribe list, so we can throttle if it looks like + // we will go over our allowed rate limit. + foreach (array_chunk($subscribers, $send_rate) as $batch) { + // Record the start of the batch sending so we can throttle if we go + // too fast for our AWS rate limit. + $timer_start = microtime(TRUE); + + // Split by the maximum number of recipients per bulk email. + foreach (array_chunk($batch, $bulk_batch_size) as $bulk_batch) { + $destinations = []; + foreach ($bulk_batch as $subscriber) { + $destinations[] = [ + 'recipient' => $subscriber->mail, + 'replacements' => [ + 'unsubscribe' => $this->generateUnsubscribeLink($subscriber->uid, $sid), + ], + ]; + } + + // AWS SES doesn't allow to customize headers when using the bulk + // endpoint so we cannot set the List-Id, List-Unsubscribe and + // X-RW-Category headers. + try { + $results = $this->awsSesClient->sendBulkEmail( + $from, + $template_name, + $destinations, + $replacements + ); + foreach ($destinations as $index => $destination) { + if (!isset($results[$index])) { + $error = 'email not processed'; + } + elseif (!empty($results[$index]['error'])) { + $error = $results[$index]['error']; + } + else { + $error = ''; + } + if (!empty($error)) { + $this->logger->error('Unable to send {name} subscription email to {recipient}: {error}', [ + 'name' => $subscription_name, + 'recipient' => $destination['recipient'], + 'error' => $error, + ]); + } + } + } + catch (\Exception $exception) { + $this->logger->error('Unable to send {name} subscription emails: {error}', [ + 'name' => $subscription_name, + 'error' => $exception->getMessage(), + ]); + } + } + + // If fewer than 1000 milliseconds have elapsed, throttle sending by + // sleeping for whatever part of a second remains after completing the + // batch. Probably not strictly necessary, but if we *do* go fast this + // can help clear a back-log of 38,000 mails just a bit faster. + $timer_elapsed = microtime(TRUE) - $timer_start; + if ($timer_elapsed < 1) { + usleep((1 - $timer_elapsed) * 1e+6); + } + } + + // Delete the template. + try { + $this->awsSesClient->deleteTemplate($template_name); + } + catch (\Exception $exception) { + $this->logger->error('Unable to delete template for {name} subscription: {error}', [ + 'name' => $subscription_name, + 'error' => $exception->getMessage(), + ]); + } + + return TRUE; + } + /** * Generate email subject. * @@ -1456,15 +1746,11 @@ protected function generateListId($sid) { protected function generateUnsubscribeLink($uid, $sid) { $path = $this->getSchemeAndHttpHost() . '/notifications/unsubscribe/user/' . $uid; $timestamp = $this->time->getRequestTime(); - $options = [ - 'absolute' => TRUE, - 'query' => [ - 'timestamp' => $timestamp, - 'signature' => $this->getSignature($path, $timestamp), - ], - ]; - $options = $this->addLinkTrackingParameters($sid, $options); - $url = Url::fromUri($path, $options); + $url = Url::fromRoute('reliefweb_subscriptions.unsubscribe', [ + 'user' => $uid, + 'timestamp' => $timestamp, + 'signature' => $this->getSignature($path, $timestamp), + ], $this->addLinkTrackingParameters($sid, ['absolute' => TRUE])); return $url->toString(); } diff --git a/html/modules/custom/reliefweb_subscriptions/src/Services/AwsSesClient.php b/html/modules/custom/reliefweb_subscriptions/src/Services/AwsSesClient.php new file mode 100644 index 000000000..37412cb15 --- /dev/null +++ b/html/modules/custom/reliefweb_subscriptions/src/Services/AwsSesClient.php @@ -0,0 +1,303 @@ +config = $config_factory->get('reliefweb_subscriptions.settings'); + $this->logger = $logger_factory->get('reliefweb_subscriptions'); + } + + /** + * Get the AWS SES API client. + * + * @return \Aws\SesV2\SesV2Client + * AWS SES API client. + */ + public function getAwsSesClient() { + if (!isset($this->awsSesV2Client)) { + $settings = [ + 'version' => $this->config->get('aws_ses_api_version'), + 'region' => $this->config->get('aws_ses_api_region'), + 'credentials' => new Credentials( + $this->config->get('aws_ses_api_key'), + $this->config->get('aws_ses_api_secret'), + $this->config->get('aws_ses_api_token') + ), + ]; + + if (!empty($this->config->get('aws_ses_api_endpoint'))) { + $settings['endpoint'] = $this->config->get('aws_ses_api_endpoint'); + } + + if (!empty($this->config->get('aws_ses_api_endpoint'))) { + $settings['endpoint'] = $this->config->get('aws_ses_api_endpoint'); + } + + $this->awsSesClient = new SesV2Client($settings); + } + return $this->awsSesClient; + } + + /** + * Create an email template. + * + * @param string $name + * Template name. + * @param string $subject + * Email subject. + * @param string $html + * HTML content. + * @param string $text + * Text content. + * + * @throws \Exception + * Exception if the request to AWS SES failed. + */ + public function createTemplate($name, $subject, $html, $text) { + // Delete the template if it already exists. + $this->deleteTemplate($name); + + // Create the template. + try { + $this->getAwsSesClient()->createEmailTemplate([ + 'TemplateName' => $name, + 'TemplateContent' => [ + 'Subject' => $subject, + 'Text' => $text, + 'Html' => $html, + ], + ]); + } + catch (AwsException $exception) { + $this->logger->error('AWS SES - Unable to create template @name: @error', [ + '@name' => $name, + '@error' => $exception->getMessage(), + ]); + // @todo extract the error message. + throw $exception; + } + } + + /** + * Delete an email template. + * + * @param string $name + * Template name. + * + * @throws \Exception + * Exception if the request to AWS SES failed. + */ + public function deleteTemplate($name) { + try { + $this->getAwsSesClient()->deleteEmailTemplate([ + 'TemplateName' => $name, + ]); + } + catch (AwsException $exception) { + if ($exception->getStatusCode() != 404) { + $this->logger->error('AWS SES - Unable to delete template @name: @error', [ + '@name' => $name, + '@error' => $exception->getMessage(), + ]); + // @todo extract the error message. + throw $exception; + } + } + } + + /** + * Send a bulk message. + * + * @param string $from + * From email address. + * @param string $template + * Name of the template to use. + * @param array $destinations + * List of recipients with a recipient key with the email address as value + * and an key/value array of replacements for the template. + * @param array $replacements + * Default key/value array of replacements for the template. + * + * @return array + * List of email sending success/error for each destination. + * + * @throws \Exception + * Exception if the request to AWS SES failed. + */ + public function sendBulkEmail($from, $template, array $destinations, array $replacements = []) { + $results = []; + + try { + $email = [ + 'FromEmailAddress' => $from, + 'DefaultContent' => [ + 'Template' => [ + 'TemplateName' => $template, + 'TemplateData' => $this->convertReplacements($replacements), + ], + ], + 'BulkEmailEntries' => array_map(function ($destination) { + return [ + 'Destination' => [ + 'ToAddresses' => [$destination['recipient']], + ], + 'ReplacementEmailContent' => [ + 'ReplacementTemplate' => [ + 'ReplacementTemplateData' => $this->convertReplacements($destination['replacements'] ?? []), + ], + ], + ]; + }, $destinations), + ]; + + $identity = $this->config->get('aws_ses_api_identity'); + if (!empty($identity)) { + $email['FromEmailAddressIdentityArn'] = $identity; + } + + /** @var \Aws\Result $result */ + $result = $this->getAwsSesClient()->SendBulkEmail($email); + + // Build the result array with the success or error for each recipient. + foreach ($result->get('BulkEmailEntryResults') as $item) { + if (strtolower($item['Status']) !== 'success') { + $results[] = [ + 'success' => FALSE, + 'error' => '[' . $item['Status'] . '] ' . $item['Error'], + ]; + } + else { + $results[] = [ + 'success' => TRUE, + ]; + } + } + } + catch (AwsException $exception) { + $this->logger->error('AWS SES - Unable to send bulk email: @error', [ + '@error' => $exception->getMessage(), + ]); + // @todo extract the error message. + throw $exception; + } + + return $results; + } + + /** + * Get the maxium number of emails that can sent in a second. + * + * @return int + * Send rate. + * + * @throws \Exception + * Exception if the request to AWS SES failed. + */ + public function getSendRate() { + try { + /** @var \Aws\Result $result */ + $result = $this->getAwsSesClient()->getAccount(); + $quota = $result->get('SendQuota'); + if (isset($quota['MaxSendRate'])) { + return intval($quota['MaxSendRate'], 10); + } + return 1; + } + catch (AwsException $exception) { + $this->logger->error('AWS SES - Unable to get send rate: @error', [ + '@error' => $exception->getMessage(), + ]); + // @todo extract the error message. + throw $exception; + } + } + + /** + * Render a template with the given replacement data. + * + * @param string $template + * The template name. + * @param array $replacements + * The replacement data. + * + * @return string + * The rendered template. + * + * @throws \Exception + * Exception if the request to AWS SES failed. + */ + public function renderTemplate($template, array $replacements = []) { + try { + /** @var \Aws\Result $result */ + $result = $this->getAwsSesClient()->testRenderTemplate([ + 'TemplateName' => $template, + 'TemplateData' => json_encode($replacements), + ]); + return $result->get('RenderedTemplate'); + } + catch (AwsException $exception) { + $this->logger->error('AWS SES - Unable to get rendered template: @error', [ + '@error' => $exception->getMessage(), + ]); + // @todo extract the error message. + throw $exception; + } + } + + /** + * Convert key value replacements into the structure expected by SES. + * + * @param array $replacements + * Key value array of replacements. + * + * @return string + * JSON encoded key/value pairs. + */ + protected function convertReplacements(array $replacements = []) { + return json_encode($replacements, \JSON_FORCE_OBJECT); + } + +} diff --git a/html/modules/custom/reliefweb_subscriptions/templates/reliefweb-subscriptions-content.html.twig b/html/modules/custom/reliefweb_subscriptions/templates/reliefweb-subscriptions-content.html.twig index f5b863e63..ce757c352 100644 --- a/html/modules/custom/reliefweb_subscriptions/templates/reliefweb-subscriptions-content.html.twig +++ b/html/modules/custom/reliefweb_subscriptions/templates/reliefweb-subscriptions-content.html.twig @@ -43,10 +43,12 @@ {%- endif -%} +{% if item.summary -%}
+{%- endif -%} {%- endfor -%} {%- endblock -%} diff --git a/html/themes/custom/common_design_subtheme/components/rw-advanced-search/rw-advanced-search.css b/html/themes/custom/common_design_subtheme/components/rw-advanced-search/rw-advanced-search.css index 9be6b362e..bc6bb1712 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-advanced-search/rw-advanced-search.css +++ b/html/themes/custom/common_design_subtheme/components/rw-advanced-search/rw-advanced-search.css @@ -13,7 +13,7 @@ /* var(--cd-reliefweb-brand-grey--light) with 0.2 opacity on white. */ background: #fafbfb; } -.rw-advanced-search:before { +.rw-advanced-search::before { position: absolute; top: -1px; bottom: -1px; @@ -71,7 +71,7 @@ border-width: 0 0 1px 0; } /* Clear any floating from the selection content. */ -.rw-advanced-search__selection:after { +.rw-advanced-search__selection::after { display: block; clear: both; width: 100%; @@ -98,9 +98,9 @@ .rw-advanced-search__selection [data-operator*="with"] { clear: both; } -.rw-advanced-search__selection [data-operator*="with"]:before, -.rw-advanced-search__selection [data-operator*="any"]:before, -.rw-advanced-search__selection [data-operator*="all"]:before { +.rw-advanced-search__selection [data-operator*="with"]::before, +.rw-advanced-search__selection [data-operator*="any"]::before, +.rw-advanced-search__selection [data-operator*="all"]::before { position: absolute; top: -1px; bottom: -1px; @@ -119,7 +119,7 @@ font-size: inherit; font-weight: inherit; } -.rw-advanced-search__selection [data-operator] button[aria-expanded]:after { +.rw-advanced-search__selection [data-operator] button[aria-expanded]::after { position: absolute; top: 50%; right: 8px; @@ -130,7 +130,7 @@ content: ""; background: var(--rw-icons--toggle--down--9--dark-blue); } -.rw-advanced-search__selection [data-operator] button[aria-expanded="true"]:after { +.rw-advanced-search__selection [data-operator] button[aria-expanded="true"]::after { background: var(--rw-icons--toggle--up--9--dark-blue); } .rw-advanced-search__selection [data-operator] ul { @@ -167,7 +167,7 @@ .rw-advanced-search__selection [data-operator] ul li[aria-disabled] { display: none; } -.rw-advanced-search__selection [data-operator] ul li:before { +.rw-advanced-search__selection [data-operator] ul li::before { position: absolute; top: 50%; left: 8px; @@ -180,7 +180,7 @@ border: none; background: var(--rw-icons--common--selected--12--dark-blue); } -.rw-advanced-search__selection [data-operator] li[aria-selected]:before { +.rw-advanced-search__selection [data-operator] li[aria-selected]::before { display: block; } .rw-advanced-search__selection [data-value] { @@ -204,7 +204,7 @@ position: relative; } /* Clear any floating from the actions. */ -.rw-advanced-search__form:after { +.rw-advanced-search__form::after { display: block; clear: both; width: 100%; @@ -242,7 +242,7 @@ padding-left: 40px; background: var(--cd-reliefweb-brand-red--dark); } -.rw-advanced-search__actions button[data-apply="true"]:before { +.rw-advanced-search__actions button[data-apply="true"]::before { position: absolute; top: 50%; left: 12px; @@ -268,7 +268,7 @@ .rw-advanced-search button[data-toggler][data-hidden="true"] { visibility: hidden; } -.rw-advanced-search button[data-toggler]:before { +.rw-advanced-search button[data-toggler]::before { position: relative; display: inline-block; overflow: hidden; @@ -319,7 +319,7 @@ display: none; } /* Clear the floating from the buttons. */ -.rw-advanced-search__filter-selector:after { +.rw-advanced-search__filter-selector::after { display: block; clear: both; width: 100%; diff --git a/html/themes/custom/common_design_subtheme/components/rw-datepicker/rw-datepicker.css b/html/themes/custom/common_design_subtheme/components/rw-datepicker/rw-datepicker.css index be9706ac1..e95f722b9 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-datepicker/rw-datepicker.css +++ b/html/themes/custom/common_design_subtheme/components/rw-datepicker/rw-datepicker.css @@ -86,7 +86,7 @@ vertical-align: top; } /* Arrow icons for the previous/next year/month. */ -.rw-datepicker-container button.rw-datepicker-control:before { +.rw-datepicker-container button.rw-datepicker-control::before { position: absolute; top: 50%; right: 50%; @@ -98,13 +98,13 @@ content: ""; background: var(--rw-icons--common--arrow-right--12--dark-blue); } -.rw-datepicker-container button.rw-datepicker-control.rw-datepicker-title-previous.rw-datepicker-title-month:before { +.rw-datepicker-container button.rw-datepicker-control.rw-datepicker-title-previous.rw-datepicker-title-month::before { background: var(--rw-icons--common--arrow-left--12--dark-blue); } -.rw-datepicker-container button.rw-datepicker-control.rw-datepicker-title-next.rw-datepicker-title-year:before { +.rw-datepicker-container button.rw-datepicker-control.rw-datepicker-title-next.rw-datepicker-title-year::before { background: var(--rw-icons--common--double-arrow-right--12--dark-blue); } -.rw-datepicker-container button.rw-datepicker-control.rw-datepicker-title-previous.rw-datepicker-title-year:before { +.rw-datepicker-container button.rw-datepicker-control.rw-datepicker-title-previous.rw-datepicker-title-year::before { background: var(--rw-icons--common--double-arrow-left--12--dark-blue); } /* Selected month and year. */ diff --git a/html/themes/custom/common_design_subtheme/components/rw-document/rw-document.css b/html/themes/custom/common_design_subtheme/components/rw-document/rw-document.css index e6ff27565..b5855eddb 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-document/rw-document.css +++ b/html/themes/custom/common_design_subtheme/components/rw-document/rw-document.css @@ -10,7 +10,7 @@ border-bottom: none; } /* Clear any floating from the content. */ -.rw-document:after { +.rw-document::after { display: block; clear: both; width: 100%; @@ -62,19 +62,19 @@ .rw-document > footer dl.rw-entity-meta dd { clear: both; } -.rw-document > footer dl.rw-entity-meta dd li:after { +.rw-document > footer dl.rw-entity-meta dd li::after { margin: 0 4px; content: " / "; } -.rw-document > footer dl.rw-entity-meta dd li:last-child:after, -.rw-document > footer dl.rw-entity-meta dd li.rw-entity-meta__tag-value__list__item--last:after { +.rw-document > footer dl.rw-entity-meta dd li:last-child::after, +.rw-document > footer dl.rw-entity-meta dd li.rw-entity-meta__tag-value__list__item--last::after { content: ""; } .rw-entity-details dl.rw-entity-meta.rw-article-meta dd::after { content: none; } /* Hide format icons */ -.rw-document > footer .rw-entity-meta__tag-value:before { +.rw-document > footer .rw-entity-meta__tag-value::before { content: none; } diff --git a/html/themes/custom/common_design_subtheme/components/rw-form/rw-form.css b/html/themes/custom/common_design_subtheme/components/rw-form/rw-form.css index 7c7e43851..744d71722 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-form/rw-form.css +++ b/html/themes/custom/common_design_subtheme/components/rw-form/rw-form.css @@ -94,7 +94,7 @@ form fieldset fieldset legend { font-size: 16px; font-weight: bold; } -form label:first-letter { +form label::first-letter { /* Try to have some consistency. */ text-transform: capitalize; } @@ -141,8 +141,8 @@ form fieldset > legend + * + * { * Ensures the loading overlay (see loading overlay component) uses the entire * viewport. */ -form[data-loading]:before, -form[data-loading]:after { +form[data-loading]::before, +form[data-loading]::after { position: fixed; } @@ -233,7 +233,7 @@ form[data-enhanced] .form-optional { form[data-enhanced] .form-required::after { content: none; } -form .form-disabled [data-irrelevant]:after { +form .form-disabled [data-irrelevant]::after { display: block; clear: both; content: attr(data-irrelevant); @@ -244,7 +244,6 @@ form .form-disabled [data-irrelevant] > div { } form input[disabled], form textarea[disabled] { - background: var(--cd-reliefweb-brand-grey--light); background: var(--cd-reliefweb-grey--disable); } form input[disabled] + label { @@ -446,7 +445,7 @@ form > #actions legend + .form-type-checkbox.form-item-notifications-content-dis justify-content: flex-end; order: 1; } -.filter-wrapper .filter-help a:after { +.filter-wrapper .filter-help a::after { top: 2px; } .filter-wrapper select { diff --git a/html/themes/custom/common_design_subtheme/components/rw-opportunities/rw-opportunities.css b/html/themes/custom/common_design_subtheme/components/rw-opportunities/rw-opportunities.css index 6f3ba6518..9dc1d156d 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-opportunities/rw-opportunities.css +++ b/html/themes/custom/common_design_subtheme/components/rw-opportunities/rw-opportunities.css @@ -42,7 +42,7 @@ font-size: 32px; font-weight: bold; } -.rw-homepage-opportunties [data-type] a:before { +.rw-homepage-opportunties [data-type] a::before { position: absolute; top: 50%; right: 22px; @@ -54,11 +54,11 @@ opacity: 0.5; background: var(--rw-icons--content--job--36--dark-blue); } -.rw-homepage-opportunties [data-type="training"] a:before { +.rw-homepage-opportunties [data-type="training"] a::before { background: var(--rw-icons--content--training--36--dark-blue); } /* Border to separate the logo from the text. */ -.rw-homepage-opportunties [data-type] a:after { +.rw-homepage-opportunties [data-type] a::after { position: absolute; top: 0; right: 79px; diff --git a/html/themes/custom/common_design_subtheme/components/rw-subscriptions/rw-subscriptions.css b/html/themes/custom/common_design_subtheme/components/rw-subscriptions/rw-subscriptions.css index daa2fa06e..0bcd4a9d5 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-subscriptions/rw-subscriptions.css +++ b/html/themes/custom/common_design_subtheme/components/rw-subscriptions/rw-subscriptions.css @@ -40,7 +40,7 @@ } .prefooter-link { text-decoration: underline; - color: #18b; + color: #055372; } .footer { padding: 15px 0 0; @@ -48,7 +48,7 @@ } .footer-link { text-decoration: underline; - color: #999; + color: #055372; } .email-title { @@ -58,7 +58,7 @@ font-family: Georgia, serif; font-size: 20px; font-weight: normal; - line-height: 20px; + line-height: 30px; } .email-title-date { color: #e55; @@ -76,12 +76,12 @@ line-height: 24px; } .email-resource-title-link { - text-decoration: none; - color: #333; + text-decoration: underline; + color: #055372; } .email-resource-info { padding: 8px 0 8px; - color: #999; + color: #666; font-size: 13px; font-weight: bold; } @@ -92,6 +92,7 @@ } .email-resource-read-more { text-decoration: underline; - color: #18b; + color: #055372; + font-size: 15px; font-weight: bold; }