From dac7be6af4212d6ae7213e458631069a11754ff8 Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Mon, 1 Apr 2019 11:31:07 -0400 Subject: [PATCH] Add support for email blacklists via $config->wireMail('blacklist') property --- wire/config.php | 28 ++++++++++ wire/core/WireMail.php | 49 +++++++++++++---- wire/core/WireMailTools.php | 105 +++++++++++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 12 deletions(-) diff --git a/wire/config.php b/wire/config.php index 8142209e..831cd8f3 100644 --- a/wire/config.php +++ b/wire/config.php @@ -1066,12 +1066,39 @@ $config->substituteModules = array( * Note you can add any other properties to the wireMail array that are supported by WireMail settings * like we’ve done with from, fromName and headers here. Any values set here become defaults for the * WireMail module. + * + * Blacklist property + * ================== + * The blacklist property lets you specify email addresses, domains, partial host names or regular + * expressions that prevent sending to certain email addresses. This is demonstrated by example: + * ~~~~~ + * // Example of blacklist definition + * $config->wireMail('blacklist', [ + * 'email@domain.com', // blacklist this email address + * '@host.domain.com', // blacklist all emails ending with @host.domain.com + * '@domain.com', // blacklist all emails ending with @domain.com + * 'domain.com', // blacklist any email address ending with domain.com (would include mydomain.com too). + * '.domain.com', // blacklist any email address at any host off domain.com (domain.com, my.domain.com, but NOT mydomain.com). + * '/something/', // blacklist any email containing "something". PCRE regex assumed when "/" is used as opening/closing delimiter. + * '/.+@really\.bad\.com$/', // another example of using a PCRE regular expression (blocks all "@really.bad.com"). + * ]); + * + * // Test out the blacklist + * $email = 'somebody@bad-domain.com'; + * $result = $mail->isBlacklistEmail($email, [ 'why' => true ]); + * if($result === false) { + * echo "

Email address is not blacklisted

"; + * } else { + * echo "

Email is blacklisted by rule: $result

"; + * } + * ~~~~~ * * #property string module Name of WireMail module to use or blank to auto-detect. (default='') * #property string from Default from email address, when none provided at runtime. (default=$config->adminEmail) * #property string fromName Default from name string, when none provided at runtime. (default='') * #property string newline What to use for newline if different from RFC standard of "\r\n" (optional). * #property array headers Default additional headers to send in email, key=value. (default=[]) + * #property array blacklist Email blacklist addresses or rules. (default=[]) * * @var array * @@ -1081,6 +1108,7 @@ $config->wireMail = array( 'from' => '', 'fromName' => '', 'headers' => array(), + 'blacklist' => array() ); /** diff --git a/wire/core/WireMail.php b/wire/core/WireMail.php index 11b22fd0..12558282 100644 --- a/wire/core/WireMail.php +++ b/wire/core/WireMail.php @@ -3,7 +3,7 @@ /** * ProcessWire WireMail * - * ProcessWire 3.x, Copyright 2016 by Ryan Cramer + * ProcessWire 3.x, Copyright 2019 by Ryan Cramer * https://processwire.com * * #pw-summary A module type that handles sending of email in ProcessWire @@ -91,17 +91,37 @@ class WireMail extends WireData implements WireMailInterface { 'attachments' => array(), ); + /** + * Construct + * + */ public function __construct() { $this->mail['header']['X-Mailer'] = "ProcessWire/" . $this->className(); parent::__construct(); } + /** + * Get property + * + * @param string $key + * @return mixed|null + * + */ public function get($key) { if($key === 'headers') $key = 'header'; if(array_key_exists($key, $this->mail)) return $this->mail[$key]; return parent::get($key); } - + + /** + * Set property + * + * @param string $key + * @param mixed $value + * + * @return $this|WireData + * + */ public function set($key, $value) { if($key === 'headers' || $key === 'header') { if(is_array($value)) $this->headers($value); @@ -117,7 +137,7 @@ class WireMail extends WireData implements WireMailInterface { public function __set($key, $value) { return $this->set($key, $value); } /** - * Sanitize an email address or throw WireException if invalid + * Sanitize an email address or throw WireException if invalid or in blacklist * * @param string $email * @return string @@ -127,9 +147,13 @@ class WireMail extends WireData implements WireMailInterface { protected function sanitizeEmail($email) { $email = strtolower(trim($email)); $clean = $this->wire('sanitizer')->email($email); - if($email != $clean) { - $clean = $this->wire('sanitizer')->entities($email); - throw new WireException("Invalid email address ($clean)"); + if($email !== $clean) { + throw new WireException("Invalid email address: " . $this->wire('sanitizer')->entities($email)); + } + /** @var WireMailTools $mail */ + $mail = $this->wire('mail'); + if($mail && $mail->isBlacklistEmail($email)) { + throw new WireException("Email address not allowed: " . $this->wire('sanitizer')->entities($email)); } return $clean; } @@ -204,7 +228,7 @@ class WireMail extends WireData implements WireMailInterface { * @param string $name Optionally provide a TO name, applicable * only when specifying #1 (single email) for the first argument. * @return $this - * @throws WireException if any provided emails were invalid + * @throws WireException if any provided emails were invalid or in blacklist * */ public function to($email = null, $name = null) { @@ -238,8 +262,10 @@ class WireMail extends WireData implements WireMailInterface { if(empty($toName)) $toName = $name; // use function arg if not overwritten $toEmail = $this->sanitizeEmail($toEmail); - $this->mail['to'][$toEmail] = $toEmail; - $this->mail['toName'][$toEmail] = $this->sanitizeHeader($toName); + if(strlen($toEmail)) { + $this->mail['to'][$toEmail] = $toEmail; + $this->mail['toName'][$toEmail] = $this->sanitizeHeader($toName); + } } return $this; @@ -272,7 +298,7 @@ class WireMail extends WireData implements WireMailInterface { * @param string $email Must be a single email address or "User Name " string. * @param string|null An optional FROM name (same as setting/calling fromName) * @return $this - * @throws WireException if provided email was invalid + * @throws WireException if provided email was invalid or in blacklist * */ public function from($email, $name = null) { @@ -307,7 +333,7 @@ class WireMail extends WireData implements WireMailInterface { * @param string $email Must be a single email address or "User Name " string. * @param string|null An optional Reply-To name (same as setting/calling replyToName method) * @return $this - * @throws WireException if provided email was invalid + * @throws WireException if provided email was invalid or in blacklist * */ public function replyTo($email, $name = null) { @@ -827,4 +853,5 @@ class WireMail extends WireData implements WireMailInterface { public function quotedPrintableString($text) { return '=?utf-8?Q?' . quoted_printable_encode($text) . '?='; } + } diff --git a/wire/core/WireMailTools.php b/wire/core/WireMailTools.php index e6bed86c..6e6d92ae 100644 --- a/wire/core/WireMailTools.php +++ b/wire/core/WireMailTools.php @@ -3,7 +3,7 @@ /** * ProcessWire Mail Tools ($mail API variable) * - * ProcessWire 3.x, Copyright 2018 by Ryan Cramer + * ProcessWire 3.x, Copyright 2019 by Ryan Cramer * https://processwire.com * * #pw-summary Provides an API interface to email and WireMail. @@ -29,6 +29,7 @@ * #pw-body * * @method WireMail new($options = array()) Create a new WireMail() instance + * @method bool|string isBlacklistEmail($email, array $options = array()) * @property WireMail new Get a new WireMail() instance (same as method version) * * @@ -386,5 +387,107 @@ class WireMailTools extends Wire { if($key === 'new') return $this->new(); return parent::__get($key); } + + /** + * Is given email address in the blacklist? + * + * - Returns boolean false if not blacklisted, true if it is. + * - Uses `$config->wireMail['blacklist']` array unless given another blacklist array in $options. + * - Always independently verify that your blacklist rules are working before assuming they do. + * - Specify true for the `why` option if you want to return the matching rule when email is in blacklist. + * - Specify true for the `throw` option if you want a WireException thrown when email is blacklisted. + * + * ~~~~~ + * // Define blacklist in /site/config.php + * $config->wireMail('blacklist', [ + * 'email@domain.com', // blacklist this email address + * '@host.domain.com', // blacklist all emails ending with @host.domain.com + * '@domain.com', // blacklist all emails ending with @domain.com + * 'domain.com', // blacklist any email address ending with domain.com (would include mydomain.com too). + * '.domain.com', // blacklist any email address at any host off domain.com (domain.com, my.domain.com, but NOT mydomain.com). + * '/something/', // blacklist any email containing "something". PCRE regex assumed when "/" is used as opening/closing delimiter. + * '/.+@really\.bad\.com$/', // another example of using a PCRE regular expression (blocks all "@really.bad.com"). + * ]); + * + * // Test if email in blacklist + * $email = 'somebody@domain.com'; + * $result = $mail->isBlacklistEmail($email, [ 'why' => true ]); + * if($result === false) { + * echo "

Email address is not blacklisted

"; + * } else { + * echo "

Email is blacklisted by rule: $result

"; + * } + * ~~~~~ + * + * @param string $email Email to check + * @param array $options + * - `blacklist` (array): Use this blacklist rather than `$config->emailBlacklist` (default=[]) + * - `throw` (bool): Throw WireException if email is blacklisted? (default=false) + * - `why` (bool): Return string containing matching rule when email is blacklisted? (default=false) + * @return bool|string Returns true if email is blacklisted, false if not. Returns string if `why` option specified + email blacklisted. + * @throws WireException if given a blacklist that is not an array, or if requested to via `throw` option. + * @since 3.0.129 + * + */ + public function ___isBlacklistEmail($email, array $options = array()) { + + $defaults = array( + 'blacklist' => array(), + 'throw' => false, + 'why' => false, + ); + + $options = count($options) ? array_merge($defaults, $options) : $defaults; + $blacklist = $options['blacklist']; + if(empty($blacklist)) $blacklist = $this->wire('config')->wireMail('blacklist'); + if(empty($blacklist)) return false; + if(!is_array($blacklist)) throw new WireException("Email blacklist must be array"); + + $inBlacklist = false; + $tt = $this->wire('sanitizer')->getTextTools(); + $email = trim($tt->strtolower($email)); + + foreach($blacklist as $line) { + $line = $tt->strtolower(trim($line)); + if(!strlen($line)) continue; + if(strpos($line, '/') === 0) { + // perform a regex match + if(preg_match($line, $email)) $inBlacklist = $line; + } else if(strpos($line, '@')) { + // full email (@ is present and is not first char) + if($email === $line) $inBlacklist = $line; + } else if(strpos($line, '.') === 0) { + // any hostname at domain (.domain.com) + list(,$emailDomain) = explode('@', $email); + if($emailDomain === ltrim($line, '.')) { + $inBlacklist = $line; + } else if($tt->substr($emailDomain, -1 * $tt->strlen($line)) === $line ) { + $inBlacklist = $line; + } + } else { + // match ending string, host or domain name (host.domain.com, domain.com) + if($tt->substr($email, -1 * $tt->strlen($line)) === $line) $inBlacklist = $line; + } + if($inBlacklist) break; + } + + if(!$inBlacklist && strpos($email, '+')) { + // leading part of email contains a plus, so check again without the "+portion" + // i.e. ryan+test@domain.com + list($prefix, $rest) = explode('+', $email, 2); + list(,$hostname) = explode('@', $rest, 2); + $email = "$prefix@$hostname"; + $inBlacklist = $this->isBlacklistEmail($email, $options); + } + + if($inBlacklist !== false && $options['throw']) { + throw new WireException("Email matches blacklist" . ($options['why'] ? " ($inBlacklist)" : "")); + } + + if(!$options['why'] && $inBlacklist !== false) $inBlacklist = true; + + return $inBlacklist; + } + } \ No newline at end of file