silverstripe-framework/src/Control/Email/Email.php

1059 lines
29 KiB
PHP

<?php
namespace SilverStripe\Control\Email;
use DateTime;
use RuntimeException;
use Egulias\EmailValidator\EmailValidator;
use Egulias\EmailValidator\Validation\RFCValidation;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTP;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Environment;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\View\Requirements;
use SilverStripe\View\SSViewer;
use SilverStripe\View\ThemeResourceLoader;
use SilverStripe\View\ViewableData;
use Swift_Message;
use Swift_Mime_SimpleMessage;
use Swift_MimePart;
/**
* Class to support sending emails.
*/
class Email extends ViewableData
{
/**
* @var array
* @config
*/
private static $send_all_emails_to = [];
/**
* @var array
* @config
*/
private static $cc_all_emails_to = [];
/**
* @var array
* @config
*/
private static $bcc_all_emails_to = [];
/**
* @var array
* @config
*/
private static $send_all_emails_from = [];
/**
* This will be set in the config on a site-by-site basis
* @see https://docs.silverstripe.org/en/4/developer_guides/email/#administrator-emails
*
* @config
* @var string|array The default administrator email address or array of [email => name]
*/
private static $admin_email = null;
/**
* @var Swift_Message
*/
private $swiftMessage;
/**
* @var string The name of the HTML template to render the email with (without *.ss extension)
*/
private $HTMLTemplate = null;
/**
* @var string The name of the plain text template to render the plain part of the email with
*/
private $plainTemplate = null;
/**
* @var Swift_MimePart
*/
private $plainPart;
/**
* @var array|ViewableData Additional data available in a template.
* Used in the same way than {@link ViewableData->customize()}.
*/
private $data = [];
/**
* @var array
*/
private $failedRecipients = [];
/**
* Checks for RFC822-valid email format.
*
* @param string $address
* @return boolean
*
* @copyright Cal Henderson <cal@iamcal.com>
* This code is licensed under a Creative Commons Attribution-ShareAlike 2.5 License
* http://creativecommons.org/licenses/by-sa/2.5/
*/
public static function is_valid_address($address)
{
$validator = new EmailValidator();
return $validator->isValid($address, new RFCValidation());
}
/**
* Get send_all_emails_to
*
* @return array Keys are addresses, values are names
*/
public static function getSendAllEmailsTo()
{
return static::mergeConfiguredEmails('send_all_emails_to', 'SS_SEND_ALL_EMAILS_TO');
}
/**
* Get cc_all_emails_to
*
* @return array
*/
public static function getCCAllEmailsTo()
{
return static::mergeConfiguredEmails('cc_all_emails_to', 'SS_CC_ALL_EMAILS_TO');
}
/**
* Get bcc_all_emails_to
*
* @return array
*/
public static function getBCCAllEmailsTo()
{
return static::mergeConfiguredEmails('bcc_all_emails_to', 'SS_BCC_ALL_EMAILS_TO');
}
/**
* Get send_all_emails_from
*
* @return array
*/
public static function getSendAllEmailsFrom()
{
return static::mergeConfiguredEmails('send_all_emails_from', 'SS_SEND_ALL_EMAILS_FROM');
}
/**
* Normalise email list from config merged with env vars
*
* @param string $config Config key
* @param string $env Env variable key
* @return array Array of email addresses
*/
protected static function mergeConfiguredEmails($config, $env)
{
// Normalise config list
$normalised = [];
$source = (array)static::config()->get($config);
foreach ($source as $address => $name) {
if ($address && !is_numeric($address)) {
$normalised[$address] = $name;
} elseif ($name) {
$normalised[$name] = null;
}
}
$extra = Environment::getEnv($env);
if ($extra) {
$normalised[$extra] = null;
}
return $normalised;
}
/**
* Encode an email-address to protect it from spambots.
* At the moment only simple string substitutions,
* which are not 100% safe from email harvesting.
*
* @param string $email Email-address
* @param string $method Method for obfuscating/encoding the address
* - 'direction': Reverse the text and then use CSS to put the text direction back to normal
* - 'visible': Simple string substitution ('@' to '[at]', '.' to '[dot], '-' to [dash])
* - 'hex': Hexadecimal URL-Encoding - useful for mailto: links
* @return string
*/
public static function obfuscate($email, $method = 'visible')
{
switch ($method) {
case 'direction':
Requirements::customCSS('span.codedirection { unicode-bidi: bidi-override; direction: rtl; }', 'codedirectionCSS');
return '<span class="codedirection">' . strrev($email) . '</span>';
case 'visible':
$obfuscated = ['@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '];
return strtr($email, $obfuscated);
case 'hex':
$encoded = '';
$emailLength = strlen($email);
for ($x = 0; $x < $emailLength; $x++) {
$encoded .= '&#x' . bin2hex($email[$x]) . ';';
}
return $encoded;
default:
user_error('Email::obfuscate(): Unknown obfuscation method', E_USER_NOTICE);
return $email;
}
}
/**
* Email constructor.
* @param string|array|null $from
* @param string|array|null $to
* @param string|null $subject
* @param string|null $body
* @param string|array|null $cc
* @param string|array|null $bcc
* @param string|null $returnPath
*/
public function __construct(
$from = null,
$to = null,
$subject = null,
$body = null,
$cc = null,
$bcc = null,
$returnPath = null
) {
if ($from) {
$this->setFrom($from);
}
if ($to) {
$this->setTo($to);
}
if ($subject) {
$this->setSubject($subject);
}
if ($body) {
$this->setBody($body);
}
if ($cc) {
$this->setCC($cc);
}
if ($bcc) {
$this->setBCC($bcc);
}
if ($returnPath) {
$this->setReturnPath($returnPath);
}
parent::__construct();
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* @return Swift_Message
*/
public function getSwiftMessage()
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
if (!$this->swiftMessage) {
$message = new Swift_Message(null, null, 'text/html', 'utf-8');
// Set priority to fix PHP 8.1 SimpleMessage::getPriority() sscanf() null parameter
$message->setPriority(Swift_Mime_SimpleMessage::PRIORITY_NORMAL);
Deprecation::withNoReplacement(function () use ($message) {
$this->setSwiftMessage($message);
});
}
return $this->swiftMessage;
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* @param Swift_Message $swiftMessage
*
* @return $this
*/
public function setSwiftMessage($swiftMessage)
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
$dateTime = new DateTime();
$dateTime->setTimestamp(DBDatetime::now()->getTimestamp());
$swiftMessage->setDate($dateTime);
if (!$swiftMessage->getFrom()) {
$swiftMessage->setFrom($this->getDefaultFrom());
}
$this->swiftMessage = $swiftMessage;
return $this;
}
/**
* @return string
*/
private function getDefaultFrom(): string
{
// admin_email can have a string or an array config
// https://docs.silverstripe.org/en/4/developer_guides/email/#administrator-emails
$adminEmail = $this->config()->get('admin_email');
if (is_array($adminEmail) && count($adminEmail ?? []) > 0) {
$defaultFrom = array_keys($adminEmail)[0];
} else {
if (is_string($adminEmail)) {
$defaultFrom = $adminEmail;
} else {
$defaultFrom = '';
}
}
if (empty($defaultFrom)) {
$host = Director::host();
if (empty($host)) {
throw new RuntimeException('Host not defined');
}
$defaultFrom = sprintf('no-reply@%s', $host);
}
$this->extend('updateDefaultFrom', $defaultFrom);
return $defaultFrom;
}
/**
* @return string[]
*/
public function getFrom()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getFrom();
});
}
/**
* @param string|array $address
* @return string|array
*/
private function sanitiseAddress($address)
{
if (is_array($address)) {
return array_map('trim', $address ?? []);
}
return trim($address ?? '');
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function setFrom($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->setFrom($address, $name);
});
return $this;
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function addFrom($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->addFrom($address, $name);
});
return $this;
}
/**
* @return string
*/
public function getSender()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getSender();
});
}
/**
* @param string $address
* @param string|null $name
* @return $this
*/
public function setSender($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->setSender($address, $name);
});
return $this;
}
/**
* @return string
*/
public function getReturnPath()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getReturnPath();
});
}
/**
* The bounce handler address
*
* @param string $address Email address where bounce notifications should be sent
* @return $this
*/
public function setReturnPath($address)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address) {
$this->getSwiftMessage()->setReturnPath($address);
});
return $this;
}
/**
* @return array
*/
public function getTo()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getTo();
});
}
/**
* Set recipient(s) of the email
*
* To send to many, pass an array:
* ['me@example.com' => 'My Name', 'other@example.com'];
*
* @param string|array $address The message recipient(s) - if sending to multiple, use an array of address => name
* @param string|null $name The name of the recipient (if one)
* @return $this
*/
public function setTo($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->setTo($address, $name);
});
return $this;
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function addTo($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->addTo($address, $name);
});
return $this;
}
/**
* @return array
*/
public function getCC()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getCc();
});
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function setCC($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->setCc($address, $name);
});
return $this;
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function addCC($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->addCc($address, $name);
});
return $this;
}
/**
* @return array
*/
public function getBCC()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getBcc();
});
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function setBCC($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->setBcc($address, $name);
});
return $this;
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function addBCC($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->addBcc($address, $name);
});
return $this;
}
/**
* @return mixed
*/
public function getReplyTo()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getReplyTo();
});
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function setReplyTo($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->setReplyTo($address, $name);
});
return $this;
}
/**
* @param string|array $address
* @param string|null $name
* @return $this
*/
public function addReplyTo($address, $name = null)
{
$address = $this->sanitiseAddress($address);
Deprecation::withNoReplacement(function () use ($address, $name) {
$this->getSwiftMessage()->addReplyTo($address, $name);
});
return $this;
}
/**
* @return string
*/
public function getSubject()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getSubject();
});
}
/**
* @param string $subject The Subject line for the email
* @return $this
*/
public function setSubject($subject)
{
Deprecation::withNoReplacement(function () use ($subject) {
$this->getSwiftMessage()->setSubject($subject);
});
return $this;
}
/**
* @return int
*/
public function getPriority()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getPriority();
});
}
/**
* @param int $priority
* @return $this
*/
public function setPriority($priority)
{
Deprecation::withNoReplacement(function () use ($priority) {
$this->getSwiftMessage()->setPriority($priority);
});
return $this;
}
/**
* @param string $path Path to file
* @param string $alias An override for the name of the file
* @param string $mime The mime type for the attachment
* @return $this
*/
public function addAttachment($path, $alias = null, $mime = null)
{
$attachment = \Swift_Attachment::fromPath($path);
if ($alias) {
$attachment->setFilename($alias);
}
if ($mime) {
$attachment->setContentType($mime);
}
$this->getSwiftMessage()->attach($attachment);
return $this;
}
/**
* @param string $data
* @param string $name
* @param string $mime
* @return $this
*/
public function addAttachmentFromData($data, $name, $mime = null)
{
$attachment = new \Swift_Attachment($data, $name);
if ($mime) {
$attachment->setContentType($mime);
}
Deprecation::withNoReplacement(function () use ($attachment) {
$this->getSwiftMessage()->attach($attachment);
});
return $this;
}
/**
* @return array|ViewableData The template data
*/
public function getData()
{
return $this->data;
}
/**
* @param array|ViewableData $data The template data to set
* @return $this
*/
public function setData($data)
{
$this->data = $data;
Deprecation::withNoReplacement(function () {
$this->invalidateBody();
});
return $this;
}
/**
* @param string|array $name The data name to add or array to names => value
* @param string|null $value The value of the data to add
* @return $this
*/
public function addData($name, $value = null)
{
if (is_array($name)) {
$this->data = array_merge($this->data, $name);
} elseif (is_array($this->data)) {
$this->data[$name] = $value;
} else {
$this->data->$name = $value;
}
Deprecation::withNoReplacement(function () {
$this->invalidateBody();
});
return $this;
}
/**
* Remove a datum from the message
*
* @param string $name
* @return $this
*/
public function removeData($name)
{
if (is_array($this->data)) {
unset($this->data[$name]);
} else {
$this->data->$name = null;
}
Deprecation::withNoReplacement(function () {
$this->invalidateBody();
});
return $this;
}
/**
* @return string
*/
public function getBody()
{
return Deprecation::withNoReplacement(function () {
return $this->getSwiftMessage()->getBody();
});
}
/**
* @param string $body The email body
* @return $this
*/
public function setBody($body)
{
Deprecation::withNoReplacement(function () use ($body) {
$plainPart = $this->findPlainPart();
if ($plainPart) {
$this->getSwiftMessage()->detach($plainPart);
}
unset($plainPart);
$body = HTTP::absoluteURLs($body);
$this->getSwiftMessage()->setBody($body);
});
return $this;
}
/**
* @deprecated 4.12.0 Will be replaced with html()
*
* @return $this
*/
public function invalidateBody()
{
Deprecation::notice('4.12.0', 'Will be replaced with html()');
$this->setBody(null);
return $this;
}
/**
* @deprecated 4.12.0 Will be replaced with getData()
*
* @return string The base URL for the email
*/
public function BaseURL()
{
Deprecation::notice('4.12.0', 'Will be replaced with getData()');
return Director::absoluteBaseURL();
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* Debugging help
*
* @return string Debug info
*/
public function debug()
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
return Deprecation::withNoReplacement(function () {
$this->render();
$class = static::class;
return "<h2>Email template {$class}:</h2>\n" . '<pre>' . $this->getSwiftMessage()->toString() . '</pre>';
});
}
/**
* @return string
*/
public function getHTMLTemplate()
{
if ($this->HTMLTemplate) {
return $this->HTMLTemplate;
}
return ThemeResourceLoader::inst()->findTemplate(
SSViewer::get_templates_by_class(static::class, '', self::class),
SSViewer::get_themes()
);
}
/**
* Set the template to render the email with
*
* @param string $template
* @return $this
*/
public function setHTMLTemplate($template)
{
if (substr($template ?? '', -3) == '.ss') {
$template = substr($template ?? '', 0, -3);
}
$this->HTMLTemplate = $template;
return $this;
}
/**
* Get the template to render the plain part with
*
* @return string
*/
public function getPlainTemplate()
{
return $this->plainTemplate;
}
/**
* Set the template to render the plain part with
*
* @param string $template
* @return $this
*/
public function setPlainTemplate($template)
{
if (substr($template ?? '', -3) == '.ss') {
$template = substr($template ?? '', 0, -3);
}
$this->plainTemplate = $template;
return $this;
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* @param array $recipients
* @return $this
*/
public function setFailedRecipients($recipients)
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
$this->failedRecipients = $recipients;
return $this;
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* @return array
*/
public function getFailedRecipients()
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
return $this->failedRecipients;
}
/**
* @deprecated 4.12.0 Will be replaced with getData()
*
* Used by {@link SSViewer} templates to detect if we're rendering an email template rather than a page template
*
* @return bool
*/
public function IsEmail()
{
Deprecation::notice('4.12.0', 'Will be replaced with getData()');
return true;
}
/**
* Send the message to the recipients
*
* @return bool true if successful or array of failed recipients
*/
public function send()
{
return Deprecation::withNoReplacement(function () {
if (!$this->getBody()) {
$this->render();
}
if (!$this->hasPlainPart()) {
$this->generatePlainPartFromBody();
}
return Injector::inst()->get(Mailer::class)->send($this);
});
}
/**
* @return array|bool
*/
public function sendPlain()
{
return Deprecation::withNoReplacement(function () {
if (!$this->hasPlainPart()) {
$this->render(true);
}
return Injector::inst()->get(Mailer::class)->send($this);
});
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* Render the email
* @param bool $plainOnly Only render the message as plain text
* @return $this
*/
public function render($plainOnly = false)
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
return Deprecation::withNoReplacement(function () use ($plainOnly) {
$existingPlainPart = Deprecation::withNoReplacement(function () {
return $this->findPlainPart();
});
if ($existingPlainPart) {
$this->getSwiftMessage()->detach($existingPlainPart);
}
unset($existingPlainPart);
// Respect explicitly set body
$htmlPart = $plainOnly ? null : $this->getBody();
$plainPart = $plainOnly ? $this->getBody() : null;
// Ensure we can at least render something
$htmlTemplate = $this->getHTMLTemplate();
$plainTemplate = $this->getPlainTemplate();
if (!$htmlTemplate && !$plainTemplate && !$plainPart && !$htmlPart) {
return $this;
}
// Do not interfere with emails styles
Requirements::clear();
// Render plain part
if ($plainTemplate && !$plainPart) {
$plainPart = $this->renderWith($plainTemplate, $this->getData())->Plain();
}
// Render HTML part, either if sending html email, or a plain part is lacking
if (!$htmlPart && $htmlTemplate && (!$plainOnly || empty($plainPart))) {
$htmlPart = $this->renderWith($htmlTemplate, $this->getData());
}
// Plain part fails over to generated from html
if (!$plainPart && $htmlPart) {
/** @var DBHTMLText $htmlPartObject */
$htmlPartObject = DBField::create_field('HTMLFragment', $htmlPart);
$plainPart = $htmlPartObject->Plain();
}
// Rendering is finished
Requirements::restore();
// Fail if no email to send
if (!$plainPart && !$htmlPart) {
return $this;
}
// Build HTML / Plain components
if ($htmlPart && !$plainOnly) {
$this->setBody($htmlPart);
$this->getSwiftMessage()->setContentType('text/html');
$this->getSwiftMessage()->setCharset('utf-8');
if ($plainPart) {
$this->getSwiftMessage()->addPart($plainPart, 'text/plain', 'utf-8');
}
} else {
if ($plainPart) {
$this->setBody($plainPart);
}
$this->getSwiftMessage()->setContentType('text/plain');
$this->getSwiftMessage()->setCharset('utf-8');
}
return $this;
});
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* @return Swift_MimePart|false
*/
public function findPlainPart()
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
return Deprecation::withNoReplacement(function () {
foreach ($this->getSwiftMessage()->getChildren() as $child) {
if ($child instanceof Swift_MimePart && $child->getContentType() == 'text/plain') {
return $child;
}
}
return false;
});
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* @return bool
*/
public function hasPlainPart()
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
return Deprecation::withNoReplacement(function () {
if ($this->getSwiftMessage()->getContentType() === 'text/plain') {
return true;
}
return (bool) $this->findPlainPart();
});
}
/**
* @deprecated 4.12.0 Will be removed without equivalent functionality to replace it
*
* Automatically adds a plain part to the email generated from the current Body
*
* @return $this
*/
public function generatePlainPartFromBody()
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality to replace it');
return Deprecation::withNoReplacement(function () {
$plainPart = $this->findPlainPart();
if ($plainPart) {
$this->getSwiftMessage()->detach($plainPart);
}
unset($plainPart);
$this->getSwiftMessage()->addPart(
Convert::xml2raw($this->getBody()),
'text/plain',
'utf-8'
);
return $this;
});
}
}