You are here

Mailer.php in Simplenews 3.x

Same filename and directory in other branches
  1. 8.2 src/Mail/Mailer.php
  2. 8 src/Mail/Mailer.php

File

src/Mail/Mailer.php
View source
<?php

namespace Drupal\simplenews\Mail;

use Drupal\Component\Utility\Unicode;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Psr\Log\LoggerInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Session\AccountSwitcherInterface;
use Drupal\Core\Session\AnonymousUserSession;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Url;
use Drupal\node\Entity\Node;
use Drupal\simplenews\Entity\Subscriber;
use Drupal\simplenews\AbortSendingException;
use Drupal\simplenews\SkipMailException;
use Drupal\simplenews\Spool\SpoolStorageInterface;
use Drupal\simplenews\SubscriberInterface;
use Drupal\Core\Messenger\MessengerTrait;
use Drupal\Core\Link;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Language\LanguageManagerInterface;

/**
 * Default Mailer.
 */
class Mailer implements MailerInterface {
  use MessengerTrait;
  use StringTranslationTrait;

  /**
   * Amount of mails after which the execution time should be checked again.
   */
  const SEND_CHECK_INTERVAL = 100;

  /**
   * At 80% of the PHP max execution time, sending is interrupted.
   */
  const SEND_TIME_LIMIT = 0.8;

  /**
   * Array indicating which status values to track results for.
   */
  const TRACK_RESULTS = [
    SpoolStorageInterface::STATUS_DONE => TRUE,
    SpoolStorageInterface::STATUS_FAILED => TRUE,
  ];

  /**
   * The simplenews spool storage.
   *
   * @var \Drupal\simplenews\Spool\SpoolStorageInterface
   */
  protected $spoolStorage;

  /**
   * The mail manager.
   *
   * @var \Drupal\Core\Mail\MailManagerInterface
   */
  protected $mailManager;

  /**
   * State service.
   *
   * @var \Drupal\Core\State\StateInterface
   */
  protected $state;

  /**
   * A logger instance.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * Account switcher.
   *
   * @var \Drupal\Core\Session\AccountSwitcherInterface
   */
  protected $accountSwitcher;

  /**
   * Lock service.
   *
   * @var \Drupal\Core\Lock\LockBackendInterface
   */
  protected $lock;

  /**
   * The config factory.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected $config;

  /**
   * Start time of the timer.
   *
   * @var float
   */
  protected $startTime;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The language manager.
   *
   * @var \Drupal\Core\Language\LanguageManagerInterface
   */
  protected $languageManager;

  /**
   * The simplenews mail cache.
   *
   * @var \Drupal\simplenews\Mail\MailCacheInterface
   */
  protected $mailCache;

  /**
   * The module handler.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * Constructs a Mailer.
   *
   * @param \Drupal\simplenews\Spool\SpoolStorageInterface $spool_storage
   *   The simplenews spool storage.
   * @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
   *   The mail manager.
   * @param \Drupal\Core\State\StateInterface $state
   *   State service.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   * @param \Drupal\Core\Session\AccountSwitcherInterface $account_switcher
   *   Account switcher.
   * @param \Drupal\Core\Lock\LockBackendInterface $lock
   *   Lock service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager service.
   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
   *   The language manager.
   * @param \Drupal\simplenews\Mail\MailCacheInterface $mail_cache
   *   The simplenews mail cache.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   */
  public function __construct(SpoolStorageInterface $spool_storage, MailManagerInterface $mail_manager, StateInterface $state, LoggerInterface $logger, AccountSwitcherInterface $account_switcher, LockBackendInterface $lock, ConfigFactoryInterface $config_factory, EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, MailCacheInterface $mail_cache, ModuleHandlerInterface $module_handler) {
    $this->spoolStorage = $spool_storage;
    $this->mailManager = $mail_manager;
    $this->state = $state;
    $this->logger = $logger;
    $this->accountSwitcher = $account_switcher;
    $this->lock = $lock;
    $this->config = $config_factory
      ->get('simplenews.settings');
    $this->entityTypeManager = $entity_type_manager;
    $this->languageManager = $language_manager;
    $this->mailCache = $mail_cache;
    $this->moduleHandler = $module_handler;
  }

  /**
   * {@inheritdoc}
   */
  public function attemptImmediateSend(array $conditions = [], $use_batch = TRUE) {
    if ($this->config
      ->get('mail.use_cron')) {
      return FALSE;
    }
    if ($use_batch) {

      // Set up as many send operations as necessary to send all mails with the
      // defined throttle amount.
      $throttle = $this->config
        ->get('mail.throttle');
      $spool_count = $this->spoolStorage
        ->countMails($conditions);
      $num_operations = ceil($spool_count / $throttle);
      $operations = [];
      for ($i = 0; $i < $num_operations; $i++) {
        $operations[] = [
          '_simplenews_batch_dispatcher',
          [
            'simplenews.mailer:sendSpool',
            $throttle,
            $conditions,
          ],
        ];
      }

      // Add separate operations to clear the spool and update the send status.
      $operations[] = [
        '_simplenews_batch_dispatcher',
        [
          'simplenews.spool_storage:clear',
        ],
      ];
      $operations[] = [
        '_simplenews_batch_dispatcher',
        [
          'simplenews.mailer:updateSendStatus',
        ],
      ];
      $batch = [
        'operations' => $operations,
        'title' => $this
          ->t('Sending mails'),
      ];
      batch_set($batch);
    }
    else {

      // Send everything that matches the conditions immediately.
      $this
        ->sendSpool(SpoolStorageInterface::UNLIMITED, $conditions);
      $this->spoolStorage
        ->clear();
      $this
        ->updateSendStatus();
    }
    return TRUE;
  }

  /**
   * {@inheritdoc}
   */
  public function sendSpool($limit = SpoolStorageInterface::UNLIMITED, array $conditions = []) {
    $check_counter = 0;

    // Send pending messages from database cache.
    $spool = $this->spoolStorage
      ->getMails($limit, $conditions);
    if (count($spool) > 0) {

      // Switch to the anonymous user.
      $anonymous_user = new AnonymousUserSession();
      $this->accountSwitcher
        ->switchTo($anonymous_user);
      $count_fail = $count_skipped = $count_success = 0;
      $sent = [];
      $this
        ->startTimer();
      try {
        while ($mail = $spool
          ->nextMail()) {
          $mail
            ->setKey('node');
          $result = $this
            ->sendMail($mail);
          $spool
            ->setLastMailResult($result);

          // Check every n emails if we exceed the limit.
          // When PHP maximum execution time is almost elapsed we interrupt
          // sending. The remainder will be sent during the next cron run.
          if (++$check_counter >= static::SEND_CHECK_INTERVAL && ini_get('max_execution_time') > 0) {
            $check_counter = 0;

            // Stop sending if a percentage of max execution time was exceeded.
            $elapsed = $this
              ->getCurrentExecutionTime();
            if ($elapsed > static::SEND_TIME_LIMIT * ini_get('max_execution_time')) {
              $this->logger
                ->warning('Sending interrupted: PHP maximum execution time almost exceeded. Remaining newsletters will be sent during the next cron run. If this warning occurs regularly you should reduce the !cron_throttle_setting.', [
                '!cron_throttle_setting' => Link::fromTextAndUrl($this
                  ->t('Cron throttle setting'), Url::fromRoute('simplenews.settings_mail')),
              ]);
              break;
            }
          }
        }
      } catch (AbortSendingException $e) {
        $this->logger
          ->error($e
          ->getMessage());
      }

      // Calculate counts.
      $results_table = [];
      $freq = array_fill(0, SpoolStorageInterface::STATUS_FAILED + 1, 0);
      foreach ($spool
        ->getResults() as $row) {
        $freq[$row->result]++;
        if (isset(static::TRACK_RESULTS[$row->result])) {
          $item =& $results_table[$row->entity_type][$row->entity_id][$row->langcode][$row->result];
          $item = ($item ?? 0) + 1;
        }
      }

      // Update subscriber count.
      if ($this->lock
        ->acquire('simplenews_update_sent_count')) {
        foreach ($results_table as $entity_type => $ids) {
          $storage = $this->entityTypeManager
            ->getStorage($entity_type);
          foreach ($ids as $entity_id => $languages) {
            $storage
              ->resetCache([
              $entity_id,
            ]);
            $entity = $storage
              ->load($entity_id);
            foreach ($languages as $langcode => $counts) {
              $translation = $entity
                ->getTranslation($langcode);
              $translation->simplenews_issue->sent_count += $counts[SpoolStorageInterface::STATUS_DONE] ?? 0;
              $translation->simplenews_issue->error_count += $counts[SpoolStorageInterface::STATUS_FAILED] ?? 0;
            }
            $entity
              ->save();
          }
        }
        $this->lock
          ->release('simplenews_update_sent_count');
      }

      // Report sent result and elapsed time. On Windows systems getrusage() is
      // not implemented and hence no elapsed time is available.
      $log_array = [
        '%success' => $freq[SpoolStorageInterface::STATUS_DONE],
        '%skipped' => $freq[SpoolStorageInterface::STATUS_SKIPPED],
        '%fail' => $freq[SpoolStorageInterface::STATUS_FAILED],
        '%retry' => $freq[SpoolStorageInterface::STATUS_PENDING],
      ];
      if (function_exists('getrusage')) {
        $log_array['%sec'] = round($this
          ->getCurrentExecutionTime(), 1);
        $this->logger
          ->notice('%success emails sent in %sec seconds, %skipped skipped, %fail failed permanently, %retry failed retrying.', $log_array);
      }
      else {
        $this->logger
          ->notice('%success emails sent, %skipped skipped, %fail failed permanently, %retry failed retrying.', $log_array);
      }
      $this->state
        ->set('simplenews.last_cron', REQUEST_TIME);
      $this->state
        ->set('simplenews.last_sent', $freq[SpoolStorageInterface::STATUS_DONE]);
      $this->accountSwitcher
        ->switchBack();
      return $freq[SpoolStorageInterface::STATUS_DONE];
    }
  }

  /**
   * {@inheritdoc}
   */
  public function sendMail(MailInterface $mail) {
    $params['simplenews_mail'] = $mail;
    if ($mail
      ->getKey('test') == 'node') {

      // Suppress error message as it causes cron failures.
      $params['_error_message'] = FALSE;
    }

    // Send mail.
    try {
      $message = $this->mailManager
        ->mail('simplenews', $mail
        ->getKey(), $mail
        ->getRecipient(), $mail
        ->getLanguage(), $params, $mail
        ->getFromFormatted());

      // Log sent result in watchdog.
      if ($this->config
        ->get('mail.debug')) {
        if ($message['result']) {
          $this->logger
            ->debug('Outgoing email. Message type: %type<br />Subject: %subject<br />Recipient: %to', [
            '%type' => $mail
              ->getKey(),
            '%to' => $message['to'],
            '%subject' => $message['subject'],
          ]);
        }
        else {
          $this->logger
            ->error('Outgoing email failed. Message type: %type<br />Subject: %subject<br />Recipient: %to', [
            '%type' => $mail
              ->getKey(),
            '%to' => $message['to'],
            '%subject' => $message['subject'],
          ]);
        }
      }

      // By default, failures are left in PENDING state to retry.
      $result = $message['result'] ? SpoolStorageInterface::STATUS_DONE : SpoolStorageInterface::STATUS_PENDING;
      $this->moduleHandler
        ->alter('simplenews_mail_result', $result, $message);
    } catch (SkipMailException $e) {
      $result = SpoolStorageInterface::STATUS_SKIPPED;
    }
    return $result;
  }

  /**
   * {@inheritdoc}
   */
  public function sendTest(ContentEntityInterface $issue, array $test_addresses) {

    // Force the current user to anonymous to ensure consistent permissions.
    $this->accountSwitcher
      ->switchTo(new AnonymousUserSession());

    // Send the test newsletter to the test address(es) specified in the node.
    // Build array of test email addresses.
    // Send newsletter to test addresses.
    // Emails are send direct, not using the spool.
    $recipients = [
      'anonymous' => [],
      'user' => [],
    ];
    foreach ($test_addresses as $mail) {
      $mail = trim($mail);
      if (!empty($mail)) {
        $subscriber = Subscriber::loadByMail($mail, 'create', $this->languageManager
          ->getCurrentLanguage());
        if ($account = $subscriber
          ->getUser()) {
          $recipients['user'][] = $account
            ->getDisplayName() . ' <' . $mail . '>';
        }
        else {
          $recipients['anonymous'][] = $mail;
        }
        $mail = new MailEntity($issue, $subscriber, $this->mailCache);
        $mail
          ->setKey('test');
        $this
          ->sendMail($mail);
      }
    }
    if (count($recipients['user'])) {
      $recipients_txt = implode(', ', $recipients['user']);
      $this
        ->messenger()
        ->addMessage($this
        ->t('Test newsletter sent to user %recipient.', [
        '%recipient' => $recipients_txt,
      ]));
    }
    if (count($recipients['anonymous'])) {
      $recipients_txt = implode(', ', $recipients['anonymous']);
      $this
        ->messenger()
        ->addMessage($this
        ->t('Test newsletter sent to anonymous %recipient.', [
        '%recipient' => $recipients_txt,
      ]));
    }
    $this->accountSwitcher
      ->switchBack();
  }

  /**
   * {@inheritdoc}
   */
  public function sendCombinedConfirmation(SubscriberInterface $subscriber) {
    $params['from'] = $this
      ->getFrom();
    $params['context']['simplenews_subscriber'] = $subscriber;
    $key = 'subscribe_combined';
    $this->mailManager
      ->mail('simplenews', $key, $subscriber
      ->getMail(), $subscriber
      ->getLangcode(), $params, $params['from']['address']);
  }

  /**
   * {@inheritdoc}
   */
  public function updateSendStatus() {

    // Number of pending emails in the spool.
    $counts = [];

    // Sum of emails in the spool per tnid (translation id)
    $sum = [];

    // Nodes with the status 'send'.
    $send = [];

    // For each pending newsletter count pending emails in the spool.
    $query = \Drupal::entityQuery('node');
    $nids = $query
      ->condition('simplenews_issue.status', SIMPLENEWS_STATUS_SEND_PENDING)
      ->accessCheck(FALSE)
      ->execute();
    $nodes = Node::loadMultiple($nids);
    if ($nodes) {
      foreach ($nodes as $nid => $node) {
        $counts[$node->simplenews_issue->target_id][$nid] = $this->spoolStorage
          ->countMails([
          'entity_id' => $nid,
          'entity_type' => 'node',
        ]);
      }
    }

    // Determine which nodes are sent per translation group and individual node.
    foreach ($counts as $newsletter_id => $node_count) {

      // The sum of emails per tnid is the combined status result for the group
      // of translated nodes. Untranslated nodes have tnid == 0 which will be
      // ignored later.
      $sum[$newsletter_id] = array_sum($node_count);
      foreach ($node_count as $nid => $count) {

        // Translated nodes (tnid != 0)
        if ($newsletter_id != '0' && $sum[$newsletter_id] == '0') {
          $send[] = $nid;
        }
        elseif ($newsletter_id == '0' && $count == '0') {
          $send[] = $nid;
        }
      }
    }

    // Update overall newsletter status.
    if (!empty($send)) {
      foreach ($send as $nid) {
        $node = Node::load($nid);
        $node->simplenews_issue->status = SIMPLENEWS_STATUS_SEND_READY;
        $node
          ->save();
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getFrom() {
    $address = $this->config
      ->get('newsletter.from_address');
    $name = $this->config
      ->get('newsletter.from_name');

    // Windows based PHP systems don't accept formatted email addresses.
    $formatted_address = mb_substr(PHP_OS, 0, 3) == 'WIN' ? $address : '"' . addslashes(Unicode::mimeHeaderEncode($name)) . '" <' . $address . '>';
    return [
      'address' => $address,
      'formatted' => $formatted_address,
    ];
  }

  /**
   * Starts the execution timer.
   */
  protected function startTimer() {

    // Windows systems don't implement getrusage(). There is no alternative.
    if (!function_exists('getrusage')) {
      return;
    }
    $usage = getrusage();
    $this->startTime = (double) ($usage['ru_stime.tv_sec'] . '.' . $usage['ru_stime.tv_usec']) + (double) ($usage['ru_utime.tv_sec'] . '.' . $usage['ru_utime.tv_usec']);
  }

  /**
   * Returns the current execution time.
   *
   * @return float|null
   *   The elapsed PHP execution time since the last start.
   *
   * @see self::startTime()
   */
  protected function getCurrentExecutionTime() {

    // Windows systems don't implement getrusage(). There is no alternative.
    if (!function_exists('getrusage')) {
      return NULL;
    }
    $usage = getrusage();
    $now = (double) ($usage['ru_stime.tv_sec'] . '.' . $usage['ru_stime.tv_usec']) + (double) ($usage['ru_utime.tv_sec'] . '.' . $usage['ru_utime.tv_usec']);
    return $now - $this->startTime;
  }

}

Classes

Namesort descending Description
Mailer Default Mailer.