You are here

reroute_email.module in Reroute Email 2.x

Intercepts all outgoing emails to be rerouted to a configurable destination.

File

reroute_email.module
View source
<?php

/**
 * @file
 * Intercepts all outgoing emails to be rerouted to a configurable destination.
 */
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Egulias\EmailValidator\EmailParser;
use Egulias\EmailValidator\EmailLexer;
define('REROUTE_EMAIL_ENABLE', 'enable');
define('REROUTE_EMAIL_ADDRESS', 'address');
define('REROUTE_EMAIL_ALLOWLIST', 'allowed');
define('REROUTE_EMAIL_DESCRIPTION', 'description');
define('REROUTE_EMAIL_MESSAGE', 'message');
define('REROUTE_EMAIL_MAILKEYS', 'mailkeys');
define('REROUTE_EMAIL_ADDRESS_EMPTY_PLACEHOLDER', '[No reroute email address configured]');

/**
 * Implements hook_module_implements_alter().
 *
 * Ensure reroute_email runs last when hook_mail_alter is invoked.
 */
function reroute_email_module_implements_alter(&$implementations, $hook) {

  // Testing with isset is only necessary if module doesn't implement the hook.
  if ($hook == 'mail_alter') {

    // Move our hook implementation to the bottom.
    $group = $implementations['reroute_email'];
    unset($implementations['reroute_email']);
    $implementations['reroute_email'] = $group;

    // If the queue_mail module is installed, ensure that comes after ours so
    // queued emails are still rerouted.
    if (isset($implementations['queue_mail'])) {
      $group = $implementations['queue_mail'];
      unset($implementations['queue_mail']);
      $implementations['queue_mail'] = $group;
    }
  }
}

/**
 * Implements hook_mail_alter().
 *
 * Alter destination of outgoing emails if reroute_email is enabled.
 */
function reroute_email_mail_alter(&$message) {
  global $base_url;
  $config = \Drupal::config('reroute_email.settings');
  if (empty($message) || !is_array($message)) {
    return;
  }

  // Allow other modules to decide whether the email should be rerouted by
  // specify a special header 'X-Rerouted-Force' to TRUE or FALSE. Any module
  // can add this header to any own emails in hook_mail or any other emails in
  // hook_mail_alter() implementations.
  if (!empty($message['headers']) && isset($message['headers']['X-Rerouted-Force'])) {
    if (FALSE === (bool) $message['headers']['X-Rerouted-Force']) {
      return;
    }

    // We ignore all module settings if X-Rerouted-Force header was set to TRUE.
  }
  elseif (reroute_email_check($message) === FALSE) {
    return;
  }
  $mailkey = isset($message['id']) ? $message['id'] : t('[mail id] is missing');
  $to = isset($message['to']) ? $message['to'] : t('[to] is missing');
  $message['headers']['X-Rerouted-Mail-Key'] = $mailkey;
  $message['headers']['X-Rerouted-Website'] = $base_url;

  // Unset Bcc and Cc fields to prevent emails from going to those addresses.
  if (isset($message['headers']) && is_array($message['headers'])) {

    // Ensure we catch all Cc and Bcc headers, regardless of case,
    // and protecting against multiple instances of the "same" header.
    $header_keys = [];
    foreach (array_keys($message['headers']) as $key) {
      $header_keys[strtolower($key)][] = $key;
    }
    if (!empty($header_keys['cc'])) {
      foreach ($header_keys['cc'] as $header) {
        $message['headers']['X-Rerouted-Original-Cc'] = $message['headers'][$header];
        unset($message['headers'][$header]);
      }
    }
    if (!empty($header_keys['bcc'])) {
      foreach ($header_keys['bcc'] as $header) {
        $message['headers']['X-Rerouted-Original-Bcc'] = $message['headers'][$header];
        unset($message['headers'][$header]);
      }
    }
  }

  // Get reroute_email_address, or use system.site.mail if not set.
  $rerouting_addresses = $config
    ->get(REROUTE_EMAIL_ADDRESS);
  if (NULL === $rerouting_addresses) {
    $rerouting_addresses = \Drupal::config('system.site')
      ->get('mail');
  }
  $message['headers']['X-Rerouted-Original-To'] = $to;
  $message['to'] = $rerouting_addresses;

  // Format a message to show at the top.
  if ($config
    ->get(REROUTE_EMAIL_DESCRIPTION)) {
    $message_lines = [
      t('This email was rerouted.'),
      t('Web site: @site', [
        '@site' => $base_url,
      ]),
      t('Mail key: @key', [
        '@key' => $mailkey,
      ]),
      t('Originally to: @to', [
        '@to' => $to,
      ]),
    ];

    // Add Cc/Bcc values to the message only if they are set.
    if (!empty($message['headers']['X-Rerouted-Original-Cc'])) {
      $message_lines[] = t('Originally cc: @cc', [
        '@cc' => $message['headers']['X-Rerouted-Original-Cc'],
      ]);
    }
    if (!empty($message['headers']['X-Rerouted-Original-Bcc'])) {
      $message_lines[] = t('Originally bcc: @bcc', [
        '@bcc' => $message['headers']['X-Rerouted-Original-Bcc'],
      ]);
    }

    // Simple separator between reroute and original messages.
    $message_lines[] = '-----------------------';
    $message_lines[] = '';
    $msg = implode(PHP_EOL, $message_lines);

    // Prepend explanation message to the body of the email. This must be
    // handled differently depending on whether the body came in as a
    // string or an array. If it came in as a string (despite the fact it
    // should be an array) we'll respect that and leave it as a string.
    if (is_string($message['body'])) {
      $message['body'] = $msg . $message['body'];
    }
    else {
      array_unshift($message['body'], $msg);
    }
  }

  // Abort sending of the email if the no rerouting addresses provided.
  if ($rerouting_addresses === '') {
    $message['send'] = FALSE;

    // Extensive params keys cause OOM error in var_export().
    unset($message['params']);

    // Simplify subject to avoid OOM error in var_export().
    if ($message['subject'] instanceof TranslatableMarkup) {
      $message['subject'] = $message['subject']
        ->render();
    }

    // Record a variable dump of the email in the recent log entries.
    $message_string = var_export($message, TRUE);
    \Drupal::logger('reroute_email')
      ->notice('Aborted email sending for <em>@message_id</em>. <br/>Detailed email data: Array $message <pre>@message</pre>', [
      '@message_id' => $message['id'],
      '@message' => $message_string,
    ]);

    // Let users know email has been aborted, but logged.
    if ($config
      ->get(REROUTE_EMAIL_MESSAGE)) {
      \Drupal::messenger()
        ->addMessage(t('<em>@message_id</em> was aborted by reroute email; site administrators can check the recent log entries for complete details on the rerouted email.', [
        '@message_id' => $message['id'],
      ]));
    }
  }
  elseif ($config
    ->get(REROUTE_EMAIL_MESSAGE)) {

    // Display a message to let users know email was rerouted.
    \Drupal::messenger()
      ->addMessage(t('Submitted email, with ID: <em>@message_id</em>, was rerouted to configured address: <em>@reroute_target</em>. For more details please refer to Reroute Email settings.', [
      '@message_id' => $message['id'],
      '@reroute_target' => $message['to'],
    ]));
  }
}

/**
 * Implements hook_mail().
 */
function reroute_email_mail($key, &$message, $params) {
  if ($message['id'] !== 'reroute_email_test_email_form') {
    return;
  }
  $message['headers']['Cc'] = $params['cc'];
  $message['headers']['Bcc'] = $params['bcc'];
  $message['subject'] = $params['subject'];
  $message['body'][] = $params['body'];
}

/**
 * Helper function to determine a need to reroute.
 *
 * @param array &$message
 *   A message array, as described in hook_mail_alter().
 *
 * @return bool
 *   Return TRUE if should be rerouted, FALSE otherwise.
 */
function reroute_email_check(array &$message) {

  // Disable rerouting according to admin settings.
  $config = \Drupal::config('reroute_email.settings');
  if (empty($config
    ->get(REROUTE_EMAIL_ENABLE))) {
    return FALSE;
  }

  // Check configured mail keys filters.
  $keys = reroute_email_split_string($config
    ->get(REROUTE_EMAIL_MAILKEYS, ''));
  if (!empty($keys) && !(in_array($message['id'], $keys, TRUE) || in_array($message['module'], $keys, TRUE))) {
    $message['headers']['X-Reroute-Status'] = 'MAILKEY-IGNORED';
    return FALSE;
  }

  // Split addresses into arrays.
  $original_addresses = reroute_email_extract_addresses($message['to']);
  $allowlisted_addresses = reroute_email_split_string($config
    ->get(REROUTE_EMAIL_ALLOWLIST));
  $allowlisted_domains = [];

  // Split allowed domains from allowed addresses.
  foreach ($allowlisted_addresses as $key => $email) {
    if (preg_match('/^\\*@(.*)$/', $email, $matches)) {

      // The part after the @ sign is the domain and according to RFC 1035,
      // section 3.1: "Name servers and resolvers must compare [domains] in a
      // case-insensitive manner".
      $domain = mb_strtolower($matches[1]);
      $allowlisted_domains[$domain] = $domain;
      unset($allowlisted_addresses[$key]);
    }
  }

  // Compare original addresses with the allow list.
  $invalid = 0;
  foreach ($original_addresses as $email) {

    // Just ignore all invalid email addresses.
    if (\Drupal::service('email.validator')
      ->isValid($email) === FALSE) {
      $invalid++;
      continue;
    }

    // Check emails and domains in the allowed list.
    $domain = mb_strtolower((new EmailParser(new EmailLexer()))
      ->parse($email)['domain']);
    if (in_array($email, $allowlisted_addresses, TRUE) || in_array($domain, $allowlisted_domains, TRUE)) {
      continue;
    }

    // No need to continue if at least one address should be rerouted.
    $message['headers']['X-Reroute-Status'] = 'REROUTED';
    return TRUE;
  }

  // Reroute if all addresses are invalid.
  if (count($original_addresses) === $invalid) {
    $message['headers']['X-Reroute-Status'] = 'INVALID-ADDRESSES';
    return TRUE;
  }

  // All email addresses are in the allowed list.
  $message['headers']['X-Reroute-Status'] = 'ALLOWLISTED';
  return FALSE;
}

/**
 * Split a string into an array by pre defined allowed delimiters.
 *
 * Items may be separated by any number and combination of:
 * spaces, commas, semicolons, or newlines.
 *
 * @param string $string
 *   A string to be split into an array.
 *
 * @return array
 *   An array of unique values from a string.
 */
function reroute_email_split_string($string) {
  $array = preg_split('/[\\s,;\\n]+/', $string, -1, PREG_SPLIT_NO_EMPTY);

  // Remove duplications.
  $array = array_unique($array);
  return $array;
}

/**
 * Extract email addresses from a string which may include display names.
 *
 * Items may be separated by any number and combination of:
 * spaces, commas, semicolons, or newlines.
 *
 * @param string $string
 *   A string to be split into an array.
 *
 * @return array
 *   An array of unique addresses from a string.
 */
function reroute_email_extract_addresses(string $string) : array {
  preg_match_all('/[^\\s,;\\n<]+@[^\\s,;\\n>]+/', $string, $addresses, PREG_PATTERN_ORDER);

  // Remove duplications.
  return array_unique($addresses[0]);
}

Functions

Namesort descending Description
reroute_email_check Helper function to determine a need to reroute.
reroute_email_extract_addresses Extract email addresses from a string which may include display names.
reroute_email_mail Implements hook_mail().
reroute_email_mail_alter Implements hook_mail_alter().
reroute_email_module_implements_alter Implements hook_module_implements_alter().
reroute_email_split_string Split a string into an array by pre defined allowed delimiters.

Constants