artabro/wire/core/WireMailTools.php

499 lines
18 KiB
PHP
Raw Permalink Normal View History

2024-08-27 11:35:37 +02:00
<?php namespace ProcessWire;
/**
* ProcessWire Mail Tools ($mail API variable)
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* #pw-summary Provides an API interface to email and WireMail.
* #pw-body =
* ~~~~~
* // Simple usage example
* $message = $mail->new();
* $message->subject('Hello world')
* ->to('user@domain.com')
* ->from('you@company.com')
* ->body('Hello there big world')
* ->bodyHTML('<h2>Hello there big world</h2>');
* $numSent = $message->send();
*
* // ProcessWire 3.0.113 lets you skip the $mail->new() call if you want:
* $numSent = $mail->subject('Hello world')
* ->to('user@domain.com')
* ->from('you@company.com')
* ->body('Hello there big world')
* ->bodyHTML('<h2>Hello there big world</h2>')
* ->send();
* ~~~~~
* #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)
*
*
*/
class WireMailTools extends Wire {
/**
* Get a new WireMail instance for sending email
*
* Note: The `$options` argument added in 3.0.123, previous versions had no $options argument.
*
* ~~~~~
* $message = $mail->new();
* $message->to('user@domain.com')->from('you@company.com');
* $message->subject('Mail Subject')->body('Mail Body Text')->bodyHTML('Body HTML');
* $numSent = $message->send();
* ~~~~~
*
*
* @param array|string $options Optional settings to override defaults, or string for `module` option:
* - `module` (string): Class name of WireMail module you want to use rather than auto detect, or 'WireMail' to force using default PHP mail.
* If requested module is not available, it will fall-back to one that is (or PHP mail), so check class name of returned value if there
* is any doubt about what WireMail module is being used.
* - You may also specify: subject, from, fromName, to, toName, subject or any other WireMail property and it will be populated.
* @return WireMail
*
*/
public function ___new($options = array()) {
if(is_string($options) && !empty($options)) $options = array('module' => $options);
if(!is_array($options)) $options = array();
/** @var WireMail|null $mail */
$mail = null;
$modules = $this->wire()->modules;
// merge config settings with requested options
$settings = $this->wire()->config->wireMail;
if(!is_array($settings)) $settings = array();
if(count($options)) $settings = array_merge($settings, $options);
// see if a specific WireMail module is requested
if(!empty($settings['module'])) {
if(strtolower($settings['module']) === 'wiremail') {
$mail = $this->wire(new WireMail());
} else {
$mail = $modules->getModule($settings['module']);
}
unset($settings['module']);
}
if(!$mail) {
// attempt to locate an installed module that overrides WireMail
foreach($modules->findByPrefix('WireMail') as $module) {
$parents = wireClassParents("$module");
if(in_array('WireMail', $parents) && $modules->isInstalled("$module")) {
$mail = $modules->get("$module");
break;
}
}
}
// if no module found, default to WireMail base class
if(!$mail) {
$mail = $this->wire(new WireMail());
}
// if anything left in settings, apply as a default setting
if(!empty($settings)) {
foreach($settings as $key => $value) {
$mail->set($key, $value);
}
}
// reset just in case module was not singular
$mail->to();
return $mail;
}
/**
* Send an email
*
* - Note that the order of arguments is slightly different from PHP's `mail()` function.
* - If no arguments are specified it simply returns a `WireMail` object (see #5 in examples).
* - This function will attempt to use an installed module that extends `WireMail`.
* If no module is installed, `WireMail` (which uses PHP mail) will be used instead.
*
* ~~~~~
* // 1. Default usage:
* $mail->send($to, $from, $subject, $body);
*
* // 2. Default usage with options array:
* $mail->send($to, $from, $subject, $body, $options);
*
* // 3. Specify body and/or bodyHTML in $options array (perhaps with other options):
* $mail->send($to, $from, $subject, $options);
*
* // 4. Specify both $body and $bodyHTML as arguments, but no $options:
* $mail->send($to, $from, $subject, $body, $bodyHTML);
*
* // 5. Specify a blank call to wireMail() to get the WireMail sending module:
* $wireMail = $mail->send();
* ~~~~~
*
* @param string|array $to Email address TO. For multiple, specify CSV string or array.
* @param string $from Email address FROM. This may be an email address, or a combined name and email address.
* Example of combined name and email: `Karen Cramer <karen@processwire.com>`
* @param string $subject Email subject
* @param string|array $body Email body or omit to move straight to $options
* @param array|string $options Array of options OR the $bodyHTML string. Array $options are:
* - `body` (string): Email body (text)
* - `bodyHTML` (string): Email body (HTML)
* - `replyTo` (string): Reply-to email address
* - `headers` (array): Associative array of header name => header value
* - Any additional options will be sent along to the WireMail module or class, in tact.
* @return int|WireMail Returns number of messages sent or WireMail object if no arguments specified.
*
*/
public function send($to = '', $from = '', $subject = '', $body = '', $options = array()) {
$mail = $this->new();
// if no $to address specified, return WireMail object
if(empty($to)) return $mail;
$defaults = array(
'body' => is_string($body) ? $body : '',
'bodyHTML' => '',
'replyTo' => '', // email address
'headers' => array(),
);
if(is_array($body)) {
// use case #2: body is provided in $options
$options = $body;
} else if(is_string($options)) {
// use case #3: body and bodyHTML are provided, but no $options
$options = array('bodyHTML' => $options);
} else {
// use case #1: default behavior
}
$options = array_merge($defaults, $options);
if(!empty($options['replyTo'])) {
$replyTo = $this->wire()->sanitizer->email($options['replyTo']);
if($replyTo) $options['headers']['Reply-to'] = $replyTo;
unset($options['replyTo']);
}
try {
// configure the mail
$mail->to($to)->subject($subject);
if(strlen($from)) $mail->from($from);
if(strlen($options['bodyHTML'])) $mail->bodyHTML($options['bodyHTML']);
if(strlen($options['body'])) $mail->body($options['body']);
if(count($options['headers'])) {
foreach($options['headers'] as $k => $v) $mail->header($k, $v);
}
// send along any options we don't recognize
foreach($options as $key => $value) {
if(!array_key_exists($key, $defaults)) $mail->$key = $value;
}
$numSent = $mail->send();
} catch(\Exception $e) {
if($this->wire()->config->debug) $mail->error($e->getMessage());
$mail->trackException($e, false);
$numSent = 0;
}
return $numSent;
}
/**
* Send an email with given message text assumed to be HTML
*
* This is just like the `$mail->send()` method with the exception that the body argument
* is assumed to be HTML rather than text. Note that the text version of the email is auto
* generated from the HTML, unless a `body` is provided in the `$options` array.
*
* @param string|array $to Email address TO. For multiple, specify CSV string or array.
* @param string $from Email address FROM. This may be an email address, or a combined name and email address.
* Example of combined name and email: `Karen Cramer <karen@processwire.com>`
* @param string $subject Email subject
* @param string $bodyHTML Email body in HTML
* @param array|string $options Array of options OR the $bodyHTML string. Array $options are:
* - `body` (string): Email body (text)
* - `replyTo` (string): Reply-to email address
* - `headers` (array): Associative array of header name => header value
* - Any additional options will be sent along to the WireMail module or class, in tact.
* @return int|WireMail Returns number of messages sent or WireMail object if no arguments specified.
*
*/
public function sendHTML($to = '', $from = '', $subject = '', $bodyHTML = '', $options = array()) {
$options['bodyHTML'] = $bodyHTML;
return $this->send($to, $from, $subject, $options);
}
/**
* Send an email, drop-in replacement for PHP mail() that uses the same arguments
*
* This is an alternative to using the `$mail->send()` method, and may be simpler for those converting
* existing PHP `mail()` calls to WireMail calls.
*
* This function duplicates the same arguments as PHPs mail function, enabling you to replace an existing
* PHP `mail(…)` call with `$mail->mail(…)`.
*
* But unlike PHPs mail function, this one can also send HTML (or multipart) emails if you provide
* an `$options` array for the `$message` argument (rather than a string). See the options array for
* the `$mail->send()` method for details.
* ~~~~~
* // 1. Basic PHP mail() style usage
* $mail->mail('ryan@processwire.com', 'Subject', 'Message body');
*
* // 2. PHP mail() style usage with with $headers argument
* $mail->mail('ryan@processwire.com', 'Subject', 'Message body', 'From: hello@world.com');
*
* // 3. Alternate usage with html and text body
* $mail->mail('ryan@processwire.com', 'Subject', [
* 'bodyHTML' => '<html><body><h1>Message HTML body</h1></body</html>',
* 'body' => 'Message text body',
* 'from' => 'hello@world.com',
* ]);
* ~~~~~
*
* @param string|array $to Email address TO. For multiple, specify CSV string or array.
* @param string $subject Email subject
* @param string|array $message Email body (PHP mail style), OR specify $options array with any of the following:
* - `bodyHTML` (string): Email body (HTML)
* - `body` (string): Email body (text). If not specified, and bodyHTML is, then text body will be auto-generated.
* - `from` (string): From email address
* - `replyTo` (string): Reply-to email address
* - `headers` (array): Associative array of header name => header value
* @param array $headers Optional additional headers as [name=value] array or "Name: Value" newline-separated string.
* Use this argument to duplicate PHP mail() style arguments. No need to use if you used $options array for the $message argument.
* @return bool True on success, false on fail.
*
*/
public function mail($to, $subject, $message, $headers = array()) {
$from = '';
if(is_string($headers)) {
$_headers = explode("\n", $headers);
$headers = array();
foreach($_headers as $header) {
if(!strpos($header, ':')) continue;
list($key, $val) = explode(':', $header, 2);
$headers[trim($key)] = trim($val);
}
}
foreach($headers as $key => $val) {
if(strtolower($key) !== 'from') continue;
$from = $val;
unset($headers[$key]);
break;
}
if(is_array($message)) {
// message is $options array
$options = $message;
if(!empty($options['headers'])) $headers = array_merge($headers, $options['headers']);
$options['headers'] = $headers;
if(isset($options['from'])) {
if(empty($from)) $from = $options['from'];
unset($options['from']);
}
$qty = $this->send($to, $from, $subject, $options);
} else {
// regular PHP style mail() call converted to $mail->send() call
$qty = $this->send($to, $from, $subject, $message, $headers);
}
return $qty > 0;
}
/**
* Send an email with message assumed to be in HTML
*
* This is the same as the `$mail->mail()` method except that the message argument is
* assumed to be HTML rather than text. The text version of the email will be auto-generated
* from the given HTML.
*
* @param string|array $to Email address TO. For multiple, specify CSV string or array.
* @param string $subject Email subject
* @param string|array Email message in HTML
* @param array $headers Optional additional headers as [name=value] array or "Name: Value" newline-separated string.
* Use this argument to duplicate PHP mail() style arguments. No need to use if you used $options array for the $message argument.
* @return bool True on success, false on fail.
* @since 3.0.109
*
*/
public function mailHTML($to, $subject, $messageHTML, $headers = array()) {
if(is_array($messageHTML)) {
$options = $messageHTML;
if(!empty($headers) && empty($options['headers'])) $options['headers'] = $headers;
} else {
$options = array(
'bodyHTML' => $messageHTML,
'headers' => $headers
);
}
return $this->mail($to, $subject, $options);
}
/**
* Return new WireMail instance populated with “to” email
*
* @param string|array $email Email to send tospecify any one of the following:
* - Single email address
* - String like: "John Smith <user@example.com>"
* - CSV string of either of the above.
* - Regular PHP array of email addresses.
* - Associative array of ['user@xample.com' => 'John Smith'].
* @param string $name An optional TO name, applies only if your $email argument was just an email address.
* @return WireMail
* @throws WireException if given invalid email address
* @since 3.0.113
*
*/
public function to($email, $name = null) {
return $this->new()->to($email, $name);
}
/**
* Return new WireMail instance populated with “from” email
*
* @param string $email Must be a single email address or "User Name <user@example.com>" string.
* @param string|null An optional FROM name
* @return WireMail
* @since 3.0.113
*
*/
public function from($email, $name = null) {
return $this->new()->from($email, $name);
}
/**
* Return new WireMail instance populated with subject
*
* @param string $subject
* @return WireMail
* @since 3.0.113
*
*/
public function subject($subject) {
return $this->new()->subject($subject);
}
public function __get($name) {
if($name === 'new') return $this->new();
return parent::__get($name);
}
/**
* 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 "<p>Email address is not blacklisted</p>";
* } else {
* echo "<p>Email is blacklisted by rule: $result</p>";
* }
* ~~~~~
*
* @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));
if(strpos($email, '@') === false) {
return $options['why'] ? "Invalid email address" : true;
}
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;
}
}