<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    core
 */

/*EXTRA FUNCTIONS: fsockopen|Mail_dispatcher_override|DKIMSignature*/

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__mail()
{
    require_lang('mail');
    require_code('type_sanitisation');

    global $SENDING_MAIL, $EMAIL_ATTACHMENTS, $SMTP_SOCKET;
    $SENDING_MAIL = false;
    $EMAIL_ATTACHMENTS = [];
    $SMTP_SOCKET = [];
    cms_register_shutdown_function_if_available(function () {
        // Close any SMTP sockets that are still open
        global $SMTP_SOCKET;
        foreach ($SMTP_SOCKET as &$socket) {
            if (($socket !== null) && ($socket !== false)) {
                @fclose($socket);
                $socket = null;
            }
        }
    });
}

/**
 * Gets a list of all system-owned e-mail addresses.
 * This is sometimes needed for internal processing.
 *
 * @param  boolean $include_all Include all e-mail domains, as opposed to just the main outgoing one
 * @return array A map of system e-mail address to domain name it uses
 */
function find_system_email_addresses(bool $include_all = true) : array
{
    $addresses = [];
    $addresses[get_option('website_email')] = true;
    if ($include_all) {
        $addresses[get_option('staff_address')] = true;
        if (addon_installed('tickets')) {
            $addresses[get_option('ticket_mail_email_address')] = true;
        }
        if (addon_installed('cns_forum')) {
            $rows = $GLOBALS['SITE_DB']->query_select('f_forums', ['f_mail_email_address']);
            foreach ($rows as $row) {
                $addresses[$row['f_mail_email_address']] = true;
            }
        }
    }

    unset($addresses['']); // None-set values should not carry through

    $_addresses = [];
    foreach (array_keys($addresses) as $address) {
        $domain = preg_replace('#^.*@#', '', $address);
        $_addresses[$address] = $domain;
    }

    return $_addresses;
}

/*
What headers to use can easily confuse. Here is a guide...

return-path    (aka envelope-from aka reverse-path)      SET BY SMTP SERVER, NOT HEADER
from           Who actually sent, SMTP-wise (should be accurate, as may be checked by SPF)
reply-to       Who replies go to
sender         Not needed, not often used
x-sender       As per sender, but might not be an e-mail address

Full details:
http://people.dsv.su.se/~jpalme/ietf/ietf-mail-attributes.html
*/

/**
 * Attempt to send an e-mail to the specified recipient. The mail will be forwarding to the CC address specified in the options (if there is one, and if not specified not to cc).
 * The mail will be sent in dual HTML/text format, where the text is based on the unconverted Comcode source: if a member does not enjoy reading HTML mail, they may wish to fallback to reading that.
 *
 * @param  string $subject_line The subject of the mail in plain text
 * @param  LONG_TEXT $message_raw The message, as Comcode
 * @param  ?array $to_emails The destination (recipient) e-mail address(es) [array of strings] (null: site staff address)
 * @param  ?mixed $to_names The recipient name(s). Array or string. (null: site name)
 * @param  EMAIL $from_email The reply-to address (blank: site staff address)
 * @param  string $from_name The from name (blank: site name)
 * @param  array $advanced_parameters A map of additional parameters. See comments within this function implementation to know what can be sent.
 * @return object Our dispatcher object, which may contain some result data
 */
function dispatch_mail(string $subject_line, string $message_raw, ?array $to_emails = null, $to_names = null, string $from_email = '', string $from_name = '', array $advanced_parameters = []) : object
{
    $dispatcher = null;

    if (class_exists('Mail_dispatcher_override')) {
        $dispatcher = new Mail_dispatcher_override($advanced_parameters);
        if (!$dispatcher->is_dispatcher_available($advanced_parameters)) {
            $dispatcher = null;
        }
    }

    if ($dispatcher === null) {
        $smtp_sockets_use = isset($advanced_parameters['smtp_sockets_use']) ? $advanced_parameters['smtp_sockets_use'] : (intval(get_option('smtp_sockets_use')) == 1); // Whether to use SMTP sockets (null: default configured)
        if ($smtp_sockets_use) {
            $dispatcher = new Mail_dispatcher_smtp($advanced_parameters);
            if (!$dispatcher->is_dispatcher_available($advanced_parameters)) {
                $dispatcher = null;
            }
        }
    }

    if ($dispatcher === null) {
        if (get_value('manualproc_mail') === '1') {
            $dispatcher = new Mail_dispatcher_manualproc($advanced_parameters);
            if (!$dispatcher->is_dispatcher_available($advanced_parameters)) {
                $dispatcher = null;
            }
        }
    }

    if ($dispatcher === null) {
        $dispatcher = new Mail_dispatcher_php($advanced_parameters);
        if (!$dispatcher->is_dispatcher_available($advanced_parameters)) {
            $dispatcher = null;
            trigger_error(do_lang('NO_PHP_EMAILING_AVAILABLE'), E_USER_NOTICE);
        }
    }

    // Some basic validation, don't allow e-mailing with bad data - front-end validation should have stopped us getting to this point, so we don't need to show nice localised errors; privacy must be protected for any e-mail addresses
    $data_errors = [];
    if (($from_email != '') && (!is_valid_email_address($from_email))) {
        $data_errors[] = 'Invalid from e-mail address';
    }
    if ($to_emails !== null) {
        foreach ($to_emails as $to_email) {
            if ((!empty($from_email)) && (!is_valid_email_address($to_email))) {
                $data_errors[] = 'Invalid destination e-mail address';
            }
        }
    }
    if (!empty($advanced_parameters['extra_cc_addresses'])) {
        foreach ($advanced_parameters['extra_cc_addresses'] as $extra_cc_address) {
            if (($extra_cc_address != '') && (!is_valid_email_address($extra_cc_address))) {
                $data_errors[] = 'Invalid CC e-mail address';
            }
        }
    }
    if (!empty($advanced_parameters['extra_bcc_addresses'])) {
        foreach ($advanced_parameters['extra_bcc_addresses'] as $extra_bcc_address) {
            if (($extra_bcc_address != '') && (!is_valid_email_address($extra_bcc_address))) {
                $data_errors[] = 'Invalid BCC e-mail address';
            }
        }
    }
    if (strlen($from_name) > 255) {
        $data_errors[] = 'From name is too long';
    }
    if ($to_names !== null) {
        foreach (is_array($to_names) ? $to_names : [$to_names] as $to_name) {
            if (strlen($to_name) > 500/*Arbitrary sanity check we are setting*/) {
                $data_errors[] = 'To name is too long';
            }
        }
    }
    if (strlen($subject_line) > 500/*Arbitrary sanity check we are setting*/) {
        $data_errors[] = 'Subject line is too long';
    }
    if (!empty($data_errors)) {
        $dispatcher->worked = false;
        $dispatcher->error = implode('; ', $data_errors);
        return $dispatcher;
    }

    global $SITE_INFO;
    if (!empty($SITE_INFO['redirect_email_output'])) {
        $to_emails = [$SITE_INFO['redirect_email_output']];
    }

    list($worked, $error) = $dispatcher->dispatch($subject_line, $message_raw, $to_emails, $to_names, $from_email, $from_name);

    $dispatcher->worked = $worked;
    $dispatcher->error = $error;

    return $dispatcher;
}

/**
 * E-mail dispatcher object. Handles the actual delivery of an e-mail over PHP's mail function.
 *
 * @package core
 */
class Mail_dispatcher_php extends Mail_dispatcher_base
{
    /**
     * Construct e-mail dispatcher.
     *
     * @param  array $advanced_parameters List of advanced parameters
     */
    public function __construct(array $advanced_parameters = [])
    {
        // Line termination is tricky. SMTP requires \r\n (PHP uses SMTP on Windows), while CLI interface must use \n.
        if (cms_strtoupper_ascii(substr(PHP_OS, 0, 3)) == 'WIN') {
            $this->line_term = "\r\n";
        } else {
            $this->line_term = "\n";
        }

        parent::__construct($advanced_parameters);
    }

    /**
     * Find whether the dispatcher instance is capable of sending e-mails.
     *
     * @param  array $advanced_parameters List of advanced parameters
     * @return boolean Whether the dispatcher instance is capable of sending e-mails
     */
    public function is_dispatcher_available(array $advanced_parameters) : bool
    {
        return true;
    }

    /**
     * Implementation-specific e-mail dispatcher, passed with pre-prepared/tidied e-mail component details for us to use.
     *
     * @param  array $to_emails To e-mail addresses
     * @param  array $to_names To names
     * @param  EMAIL $from_email Reply_to e-mail address
     * @param  string $from_name From name
     * @param  string $subject_wrapped Subject line
     * @param  string $_headers Provisional headers to use
     * @param  string $sending_message Full MIME message
     * @param  string $charset Character set to use
     * @param  string $html_evaluated Full HTML message (is also inside $sending_message, so we won't use this unless we are not using $sending_message)
     * @param  ?string $message_plain Full text message (is also inside $sending_message, so we won't use this unless we are not using $sending_message) (null: HTML only)
     * @return array A pair: Whether it worked, and an error message
     */
    protected function _dispatch(array $to_emails, array $to_names, string $from_email, string $from_name, string $subject_wrapped, string $_headers, string $sending_message, string $charset, string $html_evaluated, ?string $message_plain) : array
    {
        $worked = true;
        $error = null;

        // DKIM prep
        $dkim_private_key = get_option('dkim_private_key');
        $signed_headers = ''; // Will be filled later, potentially
        if (trim($dkim_private_key) != '') {
            require_code('mail_dkim');
        }

        foreach ($to_emails as $i => $_to_email) {
            $headers = $this->inject_unsubscribe_headers($_to_email, $_headers);

            $additional = '';
            if (($this->enveloper_override) && ($this->_sender_email !== null)) {
                if (is_valid_email_address($this->_sender_email)) { // Required for security
                    $additional = '-f ' . $this->_sender_email;
                }
            }
            $_to_name = $to_names[$i];
            if (($_to_email == $_to_name) || (cms_strtoupper_ascii(substr(PHP_OS, 0, 3)) == 'WIN')) {
                $to_line = $_to_email;
            } else {
                $to_line = '"' . $_to_name . '" <' . $_to_email . '>';
            }

            // DKIM
            if (trim($dkim_private_key) != '') {
                $signature = new DKIMSignature(trim($dkim_private_key, " \t\r\n\"'"), '', get_domain(), get_option('dkim_selector'));
                $signed_headers = str_replace("\r\n", $this->line_term, $signature->get_signed_headers($to_line, $subject_wrapped, str_replace($this->line_term, "\r\n", $sending_message), str_replace($this->line_term, "\r\n", $headers)));
            }

            $this->log('PHP-MAIL-to', $to_line);
            $this->log('PHP-MAIL-subject', $subject_wrapped);
            $this->log('PHP-MAIL-message', $sending_message);
            $this->log('PHP-MAIL-headers', $signed_headers . $headers);
            $this->log('PHP-MAIL-additional', $additional);

            if (function_exists('error_clear_last')) {
                error_clear_last();
            }
            $_worked = @mail($to_line, $subject_wrapped, str_replace(chr(0), '', $sending_message), $signed_headers . $headers, $additional);
            if ((!$worked) && (cms_error_get_last() != '')) {
                $error = cms_error_get_last();
                $worked = false;
            }
        }

        return [$worked, $error];
    }
}

/**
 * E-mail dispatcher object. Handles the actual delivery of an e-mail over SMTP.
 *
 * @package core
 */
class Mail_dispatcher_smtp extends Mail_dispatcher_base
{
    // Configuration
    public $smtp_sockets_host;
    public $smtp_sockets_port;
    public $smtp_sockets_username;
    public $smtp_sockets_password;
    public $smtp_from_address;

    /**
     * Construct e-mail dispatcher.
     *
     * @param  array $advanced_parameters List of advanced parameters
     */
    public function __construct(array $advanced_parameters = [])
    {
        $this->smtp_sockets_use = true;
        $this->smtp_sockets_host = get_option('smtp_sockets_host');
        $this->smtp_sockets_port = intval(get_option('smtp_sockets_port'));
        $this->smtp_sockets_username = get_option('smtp_sockets_username');
        $this->smtp_sockets_password = get_option('smtp_sockets_password');
        $this->smtp_from_address = get_option('smtp_from_address');

        $smtp_sockets_host = isset($advanced_parameters['smtp_sockets_host']) ? $advanced_parameters['smtp_sockets_host'] : $this->smtp_sockets_host; // SMTP hostname
        if ($smtp_sockets_host !== null) {
            $this->smtp_sockets_host = $smtp_sockets_host;
        }
        $smtp_sockets_port = isset($advanced_parameters['smtp_sockets_port']) ? $advanced_parameters['smtp_sockets_port'] : $this->smtp_sockets_port; // SMTP port
        if ($smtp_sockets_port !== null) {
            $this->smtp_sockets_port = $smtp_sockets_port;
        }
        $smtp_sockets_username = isset($advanced_parameters['smtp_sockets_username']) ? $advanced_parameters['smtp_sockets_username'] : $this->smtp_sockets_username; // SMTP username
        if ($smtp_sockets_username !== null) {
            $this->smtp_sockets_username = $smtp_sockets_username;
        }
        $smtp_sockets_password = isset($advanced_parameters['smtp_sockets_password']) ? $advanced_parameters['smtp_sockets_password'] : $this->smtp_sockets_password; // SMTP password
        if ($smtp_sockets_password !== null) {
            $this->smtp_sockets_password = $smtp_sockets_password;
        }
        $smtp_from_address = isset($advanced_parameters['smtp_from_address']) ? $advanced_parameters['smtp_from_address'] : $this->smtp_from_address; // SMTP from address
        if ($smtp_from_address !== null) {
            $this->smtp_from_address = $smtp_from_address;
        }
        if (empty($smtp_from_address)) {
            $this->smtp_from_address = $this->website_email;
        }
        if (($this->smtp_from_address === null) || (!is_valid_email_address($this->smtp_from_address))) { // Required for security
            $this->smtp_from_address = '';
        }

        $this->line_term = "\r\n";

        parent::__construct($advanced_parameters);
    }

    /**
     * Find whether the dispatcher instance is capable of sending e-mails.
     *
     * @param  array $advanced_parameters List of advanced parameters
     * @return boolean Whether the dispatcher instance is capable of sending e-mails
     */
    public function is_dispatcher_available(array $advanced_parameters) : bool
    {
        return (!empty($this->smtp_sockets_host)) && (php_function_allowed('fsockopen'));
    }

    /**
     * Send out the e-mail according to the current dispatcher configuration.
     *
     * @param  string $subject_line The subject of the mail in plain text
     * @param  LONG_TEXT $message_raw The message, as Comcode
     * @param  ?array $to_emails The destination (recipient) e-mail address(es) [array of strings] (null: site staff address)
     * @param  ?mixed $to_names The recipient name(s). Array or string. (null: site name)
     * @param  EMAIL $from_email The reply-to address (blank: site staff address)
     * @param  string $from_name The from name (blank: site name)
     * @return ?array A pair: Whether it worked, and an error message (null: skipped)
     */
    public function dispatch(string $subject_line, string $message_raw, ?array $to_emails = null, $to_names = null, string $from_email = '', string $from_name = '') : ?array
    {
        if ($from_email == '') {
            $from_email = $this->smtp_from_address;
        }

        return parent::dispatch($subject_line, $message_raw, $to_emails, $to_names, $from_email, $from_name);
    }

    /**
     * Implementation-specific e-mail dispatcher, passed with pre-prepared/tidied e-mail component details for us to use.
     *
     * @param  array $to_emails To e-mail addresses
     * @param  array $to_names To names
     * @param  EMAIL $from_email Reply-to e-mail address
     * @param  string $from_name From name
     * @param  string $subject_wrapped Subject line
     * @param  string $_headers Provisional headers to use
     * @param  string $sending_message Full MIME message
     * @param  string $charset Character set to use
     * @param  string $html_evaluated Full HTML message (is also inside $sending_message, so we won't use this unless we are not using $sending_message)
     * @param  ?string $message_plain Full text message (is also inside $sending_message, so we won't use this unless we are not using $sending_message) (null: HTML only)
     * @return array A pair: Whether it worked, and an error message
     */
    protected function _dispatch(array $to_emails, array $to_names, string $from_email, string $from_name, string $subject_wrapped, string $_headers, string $sending_message, string $charset, string $html_evaluated, ?string $message_plain) : array
    {
        $worked = false;
        $error = null;

        // DKIM prep
        $dkim_private_key = get_option('dkim_private_key');
        $signed_headers = ''; // Will be filled later, potentially
        if (trim($dkim_private_key) != '') {
            require_code('mail_dkim');
        }

        $errno = 0;
        $errstr = '';
        foreach ($to_emails as $i => $to) {
            $headers = $this->inject_unsubscribe_headers($to, $_headers);

            // Open up a connection
            global $SMTP_SOCKET;
            $cache_key = $this->smtp_sockets_host . ':' . strval($this->smtp_sockets_port);
            $socket = array_key_exists($cache_key, $SMTP_SOCKET) ? $SMTP_SOCKET[$cache_key] : null;
            $new_connection = false;
            if ($socket !== false) {
                if ($socket !== null) {
                    $this->log('RECYCLED-SOCKET', $cache_key);

                    $socket = $SMTP_SOCKET[$cache_key];

                    // Test connection still works
                    $this->fwrite($socket, "NOOP\r\n");
                    $rcv = $this->fread($socket, 1024);
                    if (substr($rcv, 0, 3) != '250') {
                        @fclose($socket);
                        $socket = null;
                    }
                }

                // Connection opening for first time, or previous connection abruptly closed - so new connection
                if ($socket === null) {
                    $this->log('OPEN-SOCKET', $cache_key);

                    $socket = @fsockopen($this->smtp_sockets_host, $this->smtp_sockets_port, $errno, $errstr, 30.0);
                    $SMTP_SOCKET[$cache_key] = $socket;
                    $new_connection = true;
                }
            }

            // If we have a connection
            if ($socket !== false) {
                if ($new_connection) {
                    $domain = get_base_url_hostname();

                    $rcv = $this->fread($socket, 1024);

                    // Log in if necessary
                    if ($this->smtp_sockets_username != '') {
                        $this->fwrite($socket, 'EHLO ' . $domain . "\r\n");
                        $rcv = $this->fread($socket, 1024);

                        $this->fwrite($socket, "AUTH LOGIN\r\n");
                        $rcv = $this->fread($socket, 1024);
                        if (substr($rcv, 0, 3) == '334') {
                            $this->fwrite($socket, base64_encode($this->smtp_sockets_username) . "\r\n");
                            $rcv = $this->fread($socket, 1024);
                            if ((substr($rcv, 0, 3) == '235') || (substr($rcv, 0, 3) == '334')) {
                                $this->fwrite($socket, base64_encode($this->smtp_sockets_password) . "\r\n");
                                $rcv = $this->fread($socket, 1024);
                                if (substr($rcv, 0, 3) == '235') {
                                } else {
                                    $error = do_lang('MAIL_ERROR_CONNECT_PASSWORD') . ' (' . str_replace($this->smtp_sockets_password, '*', $rcv) . ')';
                                }
                            } else {
                                $error = do_lang('MAIL_ERROR_CONNECT_USERNAME') . ' (' . $rcv . ')';
                            }
                        } else {
                            $error = do_lang('MAIL_ERROR_CONNECT_AUTH') . ' (' . $rcv . ')';
                        }
                    } else {
                        $this->fwrite($socket, 'HELO ' . $domain . "\r\n");
                        $rcv = $this->fread($socket, 1024);
                    }
                }

                if (($error === null)) {
                    $this->fwrite($socket, 'MAIL FROM:<' . $this->smtp_from_address . ">\r\n");
                    $rcv = $this->fread($socket, 1024);
                    if ((substr($rcv, 0, 3) == '250') || (substr($rcv, 0, 3) == '251')) {
                        $sent_one = false;
                        $this->fwrite($socket, "RCPT TO:<" . $to_emails[$i] . ">\r\n");
                        $rcv = $this->fread($socket, 1024);
                        if ((substr($rcv, 0, 3) != '250') && (substr($rcv, 0, 3) != '251')) {
                            $error = do_lang('MAIL_ERROR_TO') . ' (' . $rcv . ')' . ' ' . $to_emails[$i];
                        } else {
                            $sent_one = true;
                        }
                        if ($sent_one) {
                            $this->fwrite($socket, "DATA\r\n");
                            $rcv = $this->fread($socket, 1024);
                            if (substr($rcv, 0, 3) == '354') {
                                $to_line = '';
                                $mime_message = $this->assemble_full_mime_message($to_emails, $to_names, $i, $subject_wrapped, $signed_headers . $headers, $sending_message, $to_line);

                                // DKIM
                                if (trim($dkim_private_key) != '') {
                                    $signature = new DKIMSignature(trim($dkim_private_key, " \t\r\n\"'"), '', get_domain(), get_option('dkim_selector'));
                                    $signed_headers = str_replace("\r\n", $this->line_term, $signature->get_signed_headers($to_line, $subject_wrapped, str_replace($this->line_term, "\r\n", $sending_message), str_replace($this->line_term, "\r\n", $headers)));
                                }

                                $this->fwrite($socket, preg_replace('#^\.#m', '..', $mime_message));
                                $this->fwrite($socket, "\r\n.\r\n");
                                $rcv = $this->fread($socket, 1024);
                                if (substr($rcv, 0, 3) != '250') {
                                    $error = do_lang('MAIL_ERROR_DATA') . ' (' . $rcv . ')';
                                }
                                $this->fwrite($socket, "QUIT\r\n");
                                $rcv = $this->fread($socket, 1024);
                            } else {
                                $error = do_lang('MAIL_ERROR_DATA') . ' (' . $rcv . ')';
                            }
                        }
                    } else {
                        $error = do_lang('MAIL_ERROR_FROM') . ' (' . $rcv . ')';
                    }

                    if (@$this->fwrite($socket, "RSET\r\n") === false) { // Cut out. At least one server does this
                        @fclose($socket);
                        $socket = null;
                        $SMTP_SOCKET[$cache_key] = null;
                    } else {
                        $rcv = $this->fread($socket, 1024);
                    }
                }

                if ($socket !== null) {
                    //fclose($socket);  Let it disconnect at script end or time-out
                }
                if ($error === null) {
                    $worked = true;
                }
            } else {
                $error = do_lang('MAIL_ERROR_CONNECT', $this->smtp_sockets_host, strval($this->smtp_sockets_port));
            }
        }

        return [$worked, $error];
    }

    /**
     * Wrapper for fwrite that adds logging.
     *
     * @param  resource $handle The file handle
     * @param  string $text The string to write to the file
     * @return ~integer The number of bytes written (false: error)
     */
    protected function fwrite($handle, string $text)
    {
        $this->log('SOCKET-WRITE', static_evaluate_tempcode(with_whitespace($text)));
        return fwrite($handle, $text);
    }

    /**
     * Wrapper for fread that adds logging.
     *
     * @param  resource $handle The file handle
     * @param  integer $length Maximum length to read
     * @return ~string The read data (false: error)
     */
    protected function fread($handle, int $length)
    {
        $ret = fread($handle, $length);
        $this->log('SOCKET-READ', static_evaluate_tempcode(with_whitespace($ret)));
        return $ret;
    }
}

/**
 * E-mail dispatcher base object. Puts together an e-mail.
 *
 * @package core
 */
abstract class Mail_dispatcher_base
{
    // Extended settings
    public $priority = 3;
    public $attachments = [];
    public $no_cc = false;
    public $as = null;
    public $as_admin = false;
    public $in_html = false;
    public $bypass_queue = false;
    public $coming_out_of_queue = false;
    public $mail_template = 'MAIL';
    public $extra_cc_addresses = [];
    public $extra_bcc_addresses = [];
    public $require_recipient_valid_since = null;
    public $is_bulk = false;
    public $sender_email = null;
    public $plain_subject = false;
    public $leave_attachments_on_failure = false;

    // Configuration
    public $smtp_sockets_use = false;
    public $enveloper_override;
    public $allow_ext_images;
    public $website_email;

    // Internal settings
    protected $line_term = "\r\n";
    public $cc_addresses = [];
    public $bcc_addresses = [];
    public $real_attachments = [];
    public $cid_attachments_url_mapping = [];
    public $cid_attachments = [];
    protected $_sender_email = null;

    // For analysis of process from outside
    public $mime_data = null;

    // Return status
    public $worked = null;
    public $error = false;
    public $log = '';

    /**
     * Construct e-mail dispatcher.
     *
     * @param  array $advanced_parameters List of advanced parameters
     */
    public function __construct(array $advanced_parameters = [])
    {
        require_code('site');
        require_code('mime_types');

        $this->as = $GLOBALS['FORUM_DRIVER']->get_guest_id();

        $this->enveloper_override = (get_option('enveloper_override') == '1');
        $this->allow_ext_images = (get_option('allow_ext_images') == '1');
        $this->website_email = get_option('website_email');
        /*if ($this->website_email == '') { // Actually we want to allow it to be blank, meaning to use whatever the server is configured for
            $this->website_email = get_option('staff_address');
        }*/

        $enveloper_override = isset($advanced_parameters['enveloper_override']) ? $advanced_parameters['enveloper_override'] : $this->enveloper_override; // Use envelope override option for sendmail
        if ($enveloper_override !== null) {
            $this->enveloper_override = $enveloper_override;
        }
        $allow_ext_images = isset($advanced_parameters['allow_ext_images']) ? $advanced_parameters['allow_ext_images'] : $this->allow_ext_images; // Allow external image references rather than embedding images
        if ($allow_ext_images !== null) {
            $this->allow_ext_images = $allow_ext_images;
        }
        $website_email = isset($advanced_parameters['website_email']) ? $advanced_parameters['website_email'] : $this->website_email; // Website e-mail address
        if ($website_email !== null) {
            $this->website_email = $website_email;
        }
        if (($this->website_email === null) || (!is_valid_email_address($this->website_email))) { // Required for security
            $this->website_email = '';
        }

        $this->priority = isset($advanced_parameters['priority']) ? $advanced_parameters['priority'] : 3; // The message priority (1=urgent, 3=normal, 5=low)
        $this->attachments = isset($advanced_parameters['attachments']) ? $advanced_parameters['attachments'] : []; // A list of attachments (each attachment being a map, absolute path=>filename) (null: none)
        $this->no_cc = isset($advanced_parameters['no_cc']) ? $advanced_parameters['no_cc'] : false; // Whether to CC to the CC address
        $this->as = isset($advanced_parameters['as']) ? $advanced_parameters['as'] : $GLOBALS['FORUM_DRIVER']->get_guest_id(); // Convert Comcode->tempcode as this member (a privilege thing: we don't want people being able to use admin rights by default!) (null: guest)
        $this->as_admin = isset($advanced_parameters['as_admin']) ? $advanced_parameters['as_admin'] : false; // Replace above with arbitrary admin
        $this->in_html = isset($advanced_parameters['in_html']) ? $advanced_parameters['in_html'] : false; // HTML-only
        $this->bypass_queue = isset($advanced_parameters['bypass_queue']) ? $advanced_parameters['bypass_queue'] : (($this->priority < 3) || (strpos(serialize($this->attachments), 'tmpfile') !== false)); // Whether to bypass queueing
        $this->coming_out_of_queue = isset($advanced_parameters['coming_out_of_queue']) ? $advanced_parameters['coming_out_of_queue'] : false; // Whether to bypass queueing, because this code is running as a part of the queue management tools (null: auto-decide)
        $this->mail_template = isset($advanced_parameters['mail_template']) ? $advanced_parameters['mail_template'] : 'MAIL'; // The template used to show the e-mail
        $this->require_recipient_valid_since = isset($advanced_parameters['require_recipient_valid_since']) ? $advanced_parameters['require_recipient_valid_since'] : null; // Implement the Require-Recipient-Valid-Since header (null: no restriction)
        $this->is_bulk = isset($advanced_parameters['is_bulk']) ? $advanced_parameters['is_bulk'] : false;
        $this->sender_email = isset($advanced_parameters['sender_email']) ? $advanced_parameters['sender_email'] : null; // E-mail address to use as a sender address (null: default)
        $this->plain_subject = isset($advanced_parameters['plain_subject']) ? $advanced_parameters['plain_subject'] : false; // Avoid templating the subject to have an additional prefix/suffix
        $this->leave_attachments_on_failure = isset($advanced_parameters['leave_attachments_on_failure']) ? $advanced_parameters['leave_attachments_on_failure'] : false; // If a message may be re-sent we need to leave attachment files in place

        $this->extra_cc_addresses = isset($advanced_parameters['extra_cc_addresses']) ? $advanced_parameters['extra_cc_addresses'] : []; // Extra CC addresses to use (null: none)
        $this->extra_bcc_addresses = isset($advanced_parameters['extra_bcc_addresses']) ? $advanced_parameters['extra_bcc_addresses'] : []; // Extra BCC addresses to use (null: none)
        $this->cc_addresses = $this->extra_cc_addresses;
        $this->bcc_addresses = $this->extra_bcc_addresses;
        $cc_address = $this->no_cc ? '' : get_option('cc_address');
        if ($cc_address != '') {
            if (get_option('bcc') == '0') {
                $this->cc_addresses[] = $cc_address;
            } else {
                $this->bcc_addresses[] = $cc_address;
            }
        }
        $this->cc_addresses = array_unique($this->cc_addresses);
        $this->bcc_addresses = array_unique($this->bcc_addresses);
    }

    /**
     * Send out the e-mail according to the current dispatcher configuration.
     *
     * @param  string $subject_line The subject of the mail in plain text
     * @param  LONG_TEXT $message_raw The message, as Comcode
     * @param  ?array $to_emails The destination (recipient) e-mail address(es) [array of strings] (null: site staff address)
     * @param  ?mixed $to_names The recipient name(s). Array or string. (null: site name)
     * @param  EMAIL $from_email The reply-to address (blank: site staff address)
     * @param  string $from_name The from name (blank: site name)
     * @return ?array A pair: Whether it worked, and an error message (null: skipped)
     */
    public function dispatch(string $subject_line, string $message_raw, ?array $to_emails = null, $to_names = null, string $from_email = '', string $from_name = '') : ?array
    {
        // Attachments monitored for injection from the Comcode rendering system
        global $EMAIL_ATTACHMENTS;
        $EMAIL_ATTACHMENTS = [];

        $this->log('INIT', get_class($this));

        // May not be enabled for various reasons
        if (!$this->is_enabled()) {
            require_code('files2');
            clean_temporary_mail_attachments($this->attachments);

            return null;
        }

        // Cleanup from last send
        $this->real_attachments = [];
        $this->cid_attachments_url_mapping = [];
        $this->cid_attachments = [];

        // Normalise/check input
        $lang = get_site_default_lang(); // Returned by reference if we want something else
        $theme = 'default'; // Returned by reference if we want something else
        $this->tidy_parameters($subject_line, $message_raw, $to_emails, $to_names, $from_email, $from_name, $lang, $theme);
        if (empty($to_emails)) {
            require_code('files2');
            clean_temporary_mail_attachments($this->attachments);

            return null;
        }

        // Handle queue
        if ($this->coming_out_of_queue) {
            $queue_id = null;
        } else {
            if (!$this->in_html) {
                inject_web_resources_context_to_comcode($message_raw);
            }

            $through_queue = $this->is_through_queue();
            $queue_id = $this->log_message($through_queue, $subject_line, $message_raw, $to_emails, $to_names, $from_email, $from_name);
            if ($through_queue) {
                $this->log('QUEUED', 'Entered queue');

                return null;
            }
        }

        // Stop loops (check / lock)
        global $SENDING_MAIL;
        if ($SENDING_MAIL) {
            require_code('files2');
            clean_temporary_mail_attachments($this->attachments);

            $this->log('SKIPPED', 'Sending another e-mail currently, some kind of recursion happening');

            return null;
        }
        $SENDING_MAIL = true;

        // Profiling (start)
        if (!$this->coming_out_of_queue) {
            cms_profile_start_for('dispatch_mail');
        }

        // Go!
        list($_to_emails, $_to_names, $subject_wrapped, $headers, $sending_message, $charset, $html_evaluated, $message_plain) = $this->build_mail_components($subject_line, $message_raw, $to_emails, $to_names, $from_email, $from_name, $lang, $theme);
        list($worked, $error) = $this->_dispatch($to_emails, $to_names, $from_email, $from_name, $subject_wrapped, $headers, $sending_message, $charset, $html_evaluated, $message_plain);

        // Needs to be marked as queued, as it never sent
        if ((!$worked) && ($queue_id !== null)) {
            $GLOBALS['SITE_DB']->query_update('logged_mail_messages', ['m_queued' => 1], ['id' => $queue_id], '', 1);
        }

        // Attachment cleanup
        foreach ($this->real_attachments as $r) {
            if ($r['temp']) {
                @unlink($r['path']);
            }
        }
        foreach ($this->cid_attachments as $r) {
            if ($r['temp']) {
                @unlink($r['path']);
            }
        }

        // Profiling (end)
        if (!$this->coming_out_of_queue) {
            cms_profile_end_for('dispatch_mail', $subject_line);
        }

        // Error reporting if necessary
        if ($worked) {
            $this->log('SUCCESS', ':-)');
        } else {
            // May have already been attached (depending on our error_handling_* functions), but we'll risk double reporting because it's important
            if ($error === null) {
                $this->log('ERROR', '?');
                $_error = do_lang_tempcode('MAIL_FAIL', escape_html(get_option('staff_address')));
            } else {
                $this->log('ERROR', $error);
                $_error = make_string_tempcode($error);
            }
            attach_message($_error, 'warn', false, true);
        }

        // Stop loops (unlock)
        $SENDING_MAIL = false;

        if ($worked || !$this->leave_attachments_on_failure) {
            require_code('files2');
            clean_temporary_mail_attachments($this->attachments);
        }

        return [$worked, $error];
    }

    /**
     * Implementation-specific e-mail dispatcher, passed with pre-prepared/tidied e-mail component details for us to use.
     *
     * @param  array $to_emails To e-mail addresses
     * @param  array $to_names To names
     * @param  EMAIL $from_email Reply-to e-mail address
     * @param  string $from_name From name
     * @param  string $subject_wrapped Subject line
     * @param  string $headers Headers to use
     * @param  string $sending_message Full MIME message
     * @param  string $charset Character set to use
     * @param  string $html_evaluated Full HTML message (is also inside $sending_message, so we won't use this unless we are not using $sending_message)
     * @param  ?string $message_plain Full text message (is also inside $sending_message, so we won't use this unless we are not using $sending_message) (null: HTML only)
     * @return array A pair: Whether it worked, and an error message
     */
    abstract protected function _dispatch(array $to_emails, array $to_names, string $from_email, string $from_name, string $subject_wrapped, string $headers, string $sending_message, string $charset, string $html_evaluated, ?string $message_plain) : array;


    /**
     * Implementation-specific e-mail dispatcher, passed with pre-prepared/tidied e-mail component details for us to use.
     *
     * @param  string $subject_line The subject of the mail in plain text
     * @param  LONG_TEXT $message_raw The message, as Comcode
     * @param  array $to_emails To e-mail addresses
     * @param  array $to_names To names
     * @param  EMAIL $from_email Reply-to e-mail address
     * @param  string $from_name From name
     * @param  LANGUAGE_NAME $lang Language
     * @param  ID_TEXT $theme Theme
     * @return array A huge ordered list of mail components
     */
    protected function build_mail_components(string $subject_line, string $message_raw, array $to_emails, array $to_names, string $from_email, string $from_name, string $lang, string $theme) : array
    {
        global $EMAIL_ATTACHMENTS;

        // Our sender
        if ($this->sender_email !== null) {
            $this->_sender_email = $this->sender_email;
        } else {
            $system_addresses = find_system_email_addresses();
            if ((get_option('use_true_from') == '1') || ((get_option('use_true_from') == '0') && ((preg_replace('#^.*@#', '', $from_email) == get_domain()) || (in_array(preg_replace('#^.*@#', '', $from_email), $system_addresses))))) {
                // We either use_true_from, or we use the smart setting and the reply-to address is on the same domain as a domain we have authority over - so use the reply-to as the sender/from because it is more user-friendly to the recipient (who won't be confused about an unnecessarily drawn distinction)
                $this->_sender_email = $from_email;
            } elseif ($this->website_email != '') {
                $this->_sender_email = $this->website_email;
            }
        }

        // We use the boundary to separate message parts
        $_boundary = uniqid('CMS', true);
        $boundary = $_boundary . '_1';
        $boundary2 = $_boundary . '_2';
        $boundary3 = $_boundary . '_3';

        // Our subject
        if ($this->plain_subject) {
            $subject_wrapped = $subject_line;
        } else {
            $_subject_wrapped = do_template('MAIL_SUBJECT', ['_GUID' => '44a57c666bb00f96723256e26aade9e5', 'SUBJECT_LINE' => $subject_line], $lang, false, null, '.txt', 'text', $theme);
            $subject_wrapped = trim($_subject_wrapped->evaluate($lang));
        }

        // Apply text encoding if needed
        $regexp = '#^[\x' . dechex(32) . '-\x' . dechex(126) . ']*$#';
        $charset = ((preg_match($regexp, $subject_line . $message_raw) == 0) ? do_lang('charset', null, null, null, $lang) : 'us-ascii');
        if (preg_match($regexp, $subject_wrapped) == 0) {
            $subject_wrapped = '=?' . $charset . '?B?' . base64_encode($subject_wrapped) . "?=";
        }
        if (preg_match($regexp, $from_name) == 0) {
            $from_name = '=?' . $charset . '?B?' . base64_encode($from_name) . "?=";
        }
        foreach ($to_names as $i => $_to_name) {
            if (preg_match($regexp, $_to_name) == 0) {
                $to_names[$i] = '=?' . $charset . '?B?' . base64_encode($_to_name) . "?=";
            }
        }

        // Evaluate message. Needs doing early so we know if we have any headers
        if (!$this->in_html) {
            $cache_sig = serialize([
                $lang,
                $this->mail_template,
                $subject_wrapped,
                $theme,
                crc32($message_raw),
            ]);

            static $html_content_cache = [];
            if (isset($html_content_cache[$cache_sig])) {
                list($html_evaluated, $message_plain, $EMAIL_ATTACHMENTS) = $html_content_cache[$cache_sig];
            } else {
                require_code('media_renderer');
                push_media_mode(peek_media_mode() | MEDIA_LOWFI);

                push_lax_comcode(true);
                $html_content = comcode_to_tempcode($message_raw, $this->as, $this->as_admin);
                pop_lax_comcode();

                $message_html = null;
                $html_evaluated = null;
                $derive_css = true;
                $_html_content = $html_content->evaluate($lang);
                $_html_content = preg_replace('#(keep|for)_session=\w*#', 'filtered=1', $_html_content);
                $is_already_full_html = (stripos($_html_content, '<html') !== false);
                if ($is_already_full_html) {
                    $html_evaluated = $_html_content;
                    $derive_css = (strpos($_html_content, '{CSS') !== false);
                } else {
                    $message_html = do_template($this->mail_template, [
                        '_GUID' => 'b23069c20202aa59b7450ebf8d49cde1',
                        'CSS' => '{CSS}',
                        'LOGOURL' => get_logo_url(''),
                        'LANG' => $lang,
                        'TITLE' => $subject_wrapped,
                        'CONTENT' => $_html_content,
                    ], $lang, false, 'MAIL', '.tpl', 'templates', $theme);
                }
                if ($derive_css) {
                    require_css('email');
                    $css = css_tempcode(true, false, ($message_html === null) ? null : $message_html->evaluate($lang), $theme);
                    $_css = $css->evaluate($lang);
                    if (!$this->allow_ext_images) {
                        $_css = preg_replace_callback('#url\(["\']?(https?://[^"]*)["\']?\)#U', [$this, 'mail_css_rep_callback'], $_css);
                    }
                    if ($message_html !== null) {
                        $message_html->singular_bind('CSS', $_css);
                        $html_evaluated = $message_html->evaluate($lang);
                    }
                }

                // Cleanup the Comcode a bit
                $message_plain = strip_comcode($message_raw);
                $message_plain = static_evaluate_tempcode(do_template(
                    $this->mail_template,
                    [
                        '_GUID' => 'a23069c20202aa59b7450ebf8d49cde1',
                        'CSS' => '{CSS}',
                        'LOGOURL' => get_logo_url(''),
                        'LANG' => $lang,
                        'TITLE' => $subject_wrapped,
                        'CONTENT' => $message_plain,
                    ],
                    $lang,
                    false,
                    'MAIL',
                    '.txt',
                    'text',
                    $theme
                ));

                $html_content_cache[$cache_sig] = [$html_evaluated, $message_plain, $EMAIL_ATTACHMENTS];

                pop_media_mode();
            }
            $this->attachments = array_merge($this->attachments, $EMAIL_ATTACHMENTS);
        } else {
            $html_evaluated = $message_raw;
            $message_plain = null;
        }

        // Headers
        $headers = '';
        if ($this->_sender_email !== null) {
            $headers .= 'From: "' . $from_name . '" <' . $this->_sender_email . '>' . $this->line_term;
            $headers .= 'Return-Path: <' . $this->_sender_email . '>' . $this->line_term;
            $headers .= 'X-Sender: <' . $this->_sender_email . '>' . $this->line_term;
        }
        // else maybe server won't let us set it due to safelist security, and we must let it use it's default (i.e. accountname@hostname)
        $headers .= 'Reply-To: <' . $from_email . '>' . $this->line_term;
        if (!empty($this->cc_addresses)) {
            $headers .= 'Cc: ';
            foreach ($this->cc_addresses as $i => $cc_address) {
                if ($i != 0) {
                    $headers .= ', ';
                }
                $headers .= '<' . $cc_address . '>';
            }
            $headers .= $this->line_term;
        }
        if (!empty($this->bcc_addresses)) {
            $headers .= 'Bcc: ';
            foreach ($this->bcc_addresses as $i => $bcc_address) {
                if ($i != 0) {
                    $headers .= ', ';
                }
                $headers .= '<' . $bcc_address . '>';
            }
            $headers .= $this->line_term;
        }
        $headers .= 'Date: ' . date('r', time()) . $this->line_term;
        $headers .= 'Message-ID: <' . $_boundary . '@' . get_domain() . '>' . $this->line_term;
        $headers .= 'X-Priority: ' . strval($this->priority) . $this->line_term;
        $brand_name = get_value('rebrand_name');
        if ($brand_name === null) {
            $brand_name = DEFAULT_BRAND_NAME;
        }
        $headers .= 'X-Mailer: ' . $brand_name . $this->line_term;
        if ((count($to_emails) == 1) && ($this->require_recipient_valid_since !== null)) {
            $_require_recipient_valid_since = date('r', $this->require_recipient_valid_since);
            $headers .= 'Require-Recipient-Valid-Since: ' . $to_emails[0] . '; ' . $_require_recipient_valid_since . $this->line_term;
        }
        if ($this->is_bulk) {
            $headers .= 'Precedence: bulk' . $this->line_term;
        }
        $headers .= 'MIME-Version: 1.0' . $this->line_term;
        if (!empty($this->attachments)) {
            $headers .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"' . $this->line_term;
        } else {
            $headers .= 'Content-Type: multipart/alternative; boundary="' . $boundary2 . '"' . $this->line_term;
        }
        $sending_message = '';
        $sending_message .= 'This is a multi-part message in MIME format.' . $this->line_term . $this->line_term;
        if (!empty($this->attachments)) {
            $sending_message .= '--' . $boundary . $this->line_term;
            $sending_message .= 'Content-Type: multipart/alternative; boundary="' . $boundary2 . '"' . $this->line_term . $this->line_term . $this->line_term;
        }

        // Plain version
        if (!$this->in_html) {
            $sending_message .= '--' . $boundary2 . $this->line_term;
            $sending_message .= 'Content-Type: text/plain; charset=' . ((preg_match($regexp, $message_plain) == 0) ? $charset : 'us-ascii') . $this->line_term; // '; name="message.txt"'.  Outlook doesn't like: makes it think it's an attachment
            $sending_message .= 'Content-Transfer-Encoding: 8bit' . $this->line_term . $this->line_term;
            $sending_message .= wordwrap(str_replace("\n", $this->line_term, unixify_line_format($message_plain)) . $this->line_term, 988, $this->line_term, true);
        }

        // Make sure all inline images are referenced with img tags, otherwise some e-mail software may show it as an attachment
        foreach ($this->cid_attachments_url_mapping as $id => $img) {
            $html_evaluated .= '<!-- <img src="cid:' . $id . '" /> -->';
        }

        // HTML version
        $sending_message .= '--' . $boundary2 . $this->line_term;
        $sending_message .= 'Content-Type: multipart/related; boundary="' . $boundary3 . '"' . $this->line_term . $this->line_term . $this->line_term;
        $sending_message .= '--' . $boundary3 . $this->line_term;
        $sending_message .= 'Content-Type: text/html; charset=' . ((preg_match($regexp, $html_evaluated) == 0) ? $charset : 'us-ascii') . $this->line_term; // .'; name="message.html"'. Outlook doesn't like: makes it think it's an attachment
        if (!$this->allow_ext_images) {
            $cid_before = array_keys($this->cid_attachments_url_mapping);
            $html_evaluated = preg_replace_callback('#<img\s([^>]*)src="(https?://[^"]*)"#U', [$this, 'mail_img_rep_callback'], $html_evaluated);
            $cid_just_html = array_diff(array_keys($this->cid_attachments_url_mapping), $cid_before);
            $matches = [];
            foreach (['#<([^"<>]*\s)style="([^"]*)"#', '#<style( [^<>]*)?' . '>(.*)</style>#Us'] as $over) {
                $num_matches = preg_match_all($over, $html_evaluated, $matches);
                for ($i = 0; $i < $num_matches; $i++) {
                    $altered_inner = preg_replace_callback('#url\(["\']?(https?://[^"]*)["\']?\)#U', [$this, 'mail_css_rep_callback'], $matches[2][$i]);
                    if ($matches[2][$i] != $altered_inner) {
                        $altered_outer = str_replace($matches[2][$i], $altered_inner, $matches[0][$i]);
                        $html_evaluated = str_replace($matches[0][$i], $altered_outer, $html_evaluated);
                    }
                }
            }

            // This is a hack to stop the images used by CSS showing as attachments in some mail clients
            $cid_just_css = array_diff(array_keys($this->cid_attachments_url_mapping), $cid_just_html);
            foreach ($cid_just_css as $cid) {
                $html_evaluated .= '<img width="0" height="0" src="cid:' . $cid . '" />';
            }
        }
        $sending_message .= 'Content-Transfer-Encoding: 8bit' . $this->line_term . $this->line_term; // Requires RFC 1652
        $sending_message .= wordwrap(str_replace("\n", $this->line_term, unixify_line_format($html_evaluated)) . $this->line_term, 988, $this->line_term, true);

        // Inline images
        $total_filesize = 0;
        foreach ($this->cid_attachments_url_mapping as $id => $img) {
            $test = $this->get_image_for_cid($img, $this->as, $total_filesize);
            if ($test === null) {
                continue;
            }
            list($mime_type, $filename, $file_contents) = $test;

            $sending_message .= '--' . $boundary3 . $this->line_term;
            $sending_message .= 'Content-Type: ' . escape_header($mime_type) . '; name="' . escape_header($filename) . '"' . $this->line_term;
            $sending_message .= 'Content-Transfer-Encoding: base64' . $this->line_term . $this->line_term;
            $sending_message .= 'Content-ID: <' . $id . '>' . $this->line_term;
            $sending_message .= 'Content-Disposition: inline; filename="' . escape_header($filename) . '"' . $this->line_term . $this->line_term;
            if (is_string($file_contents)) {
                $sending_message .= chunk_split(base64_encode($file_contents), 76, $this->line_term);
            }

            $cid_attachment = [
                'mime' => $mime_type,
                'filename' => $filename,
                'contents' => $file_contents,
                'temp' => false,
                'cid' => $id,
            ];
            $this->cid_attachments[] = $cid_attachment;
        }
        $sending_message .= $this->line_term . '--' . $boundary3 . '--' . $this->line_term;

        $sending_message .= $this->line_term . '--' . $boundary2 . '--' . $this->line_term;

        // Attachments
        $this->real_attachments = [];
        foreach ($this->attachments as $path => $filename) {
            $mime_type = get_mime_type(get_file_extension($filename), has_privilege($this->as, 'comcode_dangerous'));

            if ((strpos($path, '://') === false) && (substr($path, 0, 5) != 'gs://')) {
                if (!is_file($path)) {
                    continue;
                }
                $contents = cms_file_get_contents_safe($path, FILE_READ_LOCK);

                $real_attachment = [
                    'mime' => $mime_type,
                    'filename' => $filename,
                    'path' => $path,
                    'temp' => false, // It may be a second reference to a temp file, so we won't mark it as needing cleanup here
                ];
            } else {
                $result = cms_http_request($path, ['trigger_error' => false]);
                if ($result->data === null) {
                    continue;
                }
                if ($result->mime_type !== null) {
                    $mime_type = $result->download_mime_type;
                }

                $real_attachment = [
                    'mime' => $mime_type,
                    'filename' => $filename,
                    'contents' => $result->data,
                    'temp' => false,
                ];
            }

            $sending_message .= '--' . $boundary . $this->line_term;
            $sending_message .= 'Content-Type: ' . $mime_type . $this->line_term; // . '; name="' . clean_parameter($filename).'"'   http://www.imc.org/ietf-822/old-archive2/msg02121.html
            $sending_message .= 'Content-Transfer-Encoding: base64' . $this->line_term;
            $sending_message .= 'Content-Disposition: attachment; filename="' . escape_header($filename) . '"' . $this->line_term . $this->line_term;

            $sending_message .= chunk_split(base64_encode($contents), 76, $this->line_term);

            $sf_prefix = get_custom_file_base() . '/temp/';
            if (substr($path, 0, strlen($sf_prefix)) == $sf_prefix) {
                @unlink($path);
                sync_file($path);
            }

            $this->real_attachments[] = $real_attachment;

            $sending_message .= $this->line_term . '--' . $boundary . '--' . $this->line_term;
        }

        // Pull it together
        $to_line = '';
        $this->mime_data = $this->assemble_full_mime_message($to_emails, $to_names, 0, $subject_wrapped, $headers, $sending_message, $to_line);

        // Return what we need
        return [$to_emails, $to_names, $subject_wrapped, $headers, $sending_message, $charset, $html_evaluated, $message_plain];
    }

    /**
     * Build a full mime message from components.
     *
     * @param  array $to_emails Recipient e-mails
     * @param  array $to_names Recipient names
     * @param  integer $i Position in recipients
     * @param  string $tightened_subject Subject
     * @param  string $headers Headers
     * @param  string $sending_message Message body
     * @param  string $to_line To line
     * @return string The mime message
     */
    protected function assemble_full_mime_message(array $to_emails, array $to_names, int $i, string $tightened_subject, string $headers, string $sending_message, string &$to_line) : string
    {
        $full_mime_message = '';

        if (count($to_emails) == 1) {
            if ($to_emails[$i] == $to_names[$i]) {
                $to_line = $to_emails[$i] . $this->line_term;
            } else {
                $to_line = $to_names[$i] . ' <' . $to_emails[$i] . '>' . $this->line_term;
            }
            $full_mime_message .= 'To: ' . $to_line;
        } else {
            $full_mime_message .= 'To: ' . $to_names[$i] . $this->line_term;
        }

        $full_mime_message .= 'Subject: ' . $tightened_subject . $this->line_term;

        $full_mime_message .= $headers;

        $full_mime_message .= $this->line_term . $sending_message;

        return $full_mime_message;
    }

    /**
     * Tidy up complex input data, and make some conclusions. Returns by reference.
     *
     * @param  string $subject_line The subject of the mail in plain text
     * @param  LONG_TEXT $message_raw The message, as Comcode
     * @param  ?array $to_emails The destination (recipient) e-mail address(es) [array of strings] (null: site staff address)
     * @param  ?mixed $to_names The recipient name(s). Array or string. (null: site name)
     * @param  EMAIL $from_email Reply-to e-mail address
     * @param  string $from_name From name
     * @param  LANGUAGE_NAME $lang Language
     * @param  ID_TEXT $theme Theme
     */
    protected function tidy_parameters(string &$subject_line, string &$message_raw, ?array &$to_emails, &$to_names, string &$from_email, string &$from_name, string &$lang, string &$theme)
    {
        require_code('mail2');

        escape_header($subject_line);

        $staff_address = get_option('staff_address');
        if (!is_valid_email_address($staff_address)) { // Required for security
            $staff_address = '';
        }

        // Filter e-mails to which we are not allowed to send
        foreach ($to_emails as $key => $email) {
            if (!can_email_address($email)) {
                $this->log('SKIPPED', $email . ' is either unsubscribed or had a bounce in the last 8 weeks.');

                unset($to_emails[$key]);
            }
        }

        // Filter our e-mails of banned members
        if ($this->priority != 1 && $to_emails !== null) {
            foreach ($to_emails as $key => $email) {
                $member_id = $GLOBALS['FORUM_DRIVER']->get_member_from_email_address($email);
                if (($member_id !== null) && ($GLOBALS['FORUM_DRIVER']->is_banned($member_id))) {
                    $this->log('SKIPPED', $email . ' is for a banned member, and we are not sending on priority 1.');

                    unset($to_emails[$key]);
                }
            }
        }

        // To e-mail (an array)
        if ($to_emails === null) {
            $to_emails = [$staff_address];
        }
        $to_emails_new = [];
        foreach ($to_emails as $_to_email) {
            escape_header($_to_email);
            if ($_to_email != '') {
                $to_emails_new[] = $_to_email;
            }
        }
        $to_emails = $to_emails_new;

        // To name (an array)
        if ((is_array($to_names)) && (!empty($to_names))) {
            if ($to_emails[0] == $staff_address) {
                $to_names = [];
                for ($i = 0; $i < count($to_emails); $i++) {
                    $to_names[] = get_site_name();
                }
            } else {
                $to_names = $to_emails;
            }
        } elseif ($to_names === null) {
            $to_names = [get_site_name()];
        } elseif (!is_array($to_names)) {
            $to_names = [$to_names];
        }
        for ($i = count($to_names); $i < count($to_emails); $i++) {
            $to_names[] = $to_names[0];
        }
        foreach ($to_names as &$_to_name) {
            if ($_to_name !== null) {
                $_to_name = preg_replace('#@.*$#', '', $_to_name); // preg_replace is because some servers may reject sending names that look like e-mail addresses. The software tries this from recommend module.
                escape_header($_to_name);
            }
        }

        // From e-mail
        if ($from_email == '') {
            $from_email = get_option('staff_address');
        }
        if (!is_valid_email_address($from_email)) { // Required for security
            $from_email = '';
        }
        escape_header($from_email);

        // From name
        if ($from_name == '') {
            $from_name = get_site_name();
        }
        escape_header($from_name);

        $member_id = null;
        if (count($to_emails) == 1) {
            $member_id = $GLOBALS['FORUM_DRIVER']->get_member_from_email_address($to_emails[0]);
        }
        if (($member_id === null) && (count($to_emails) > 1)) {
            $member_id = $GLOBALS['FORUM_DRIVER']->get_guest_id();
        }

        // Language
        if ($member_id === null) {
            $lang = user_lang();
        } else {
            $lang = get_lang($member_id);
        }

        if ($member_id === null) {
            $member_id = $GLOBALS['FORUM_DRIVER']->get_guest_id(); // As there's no browser-settings involved (as for user_lang()) we just want to set for what a guest would see
        }

        // Theme
        $theme = get_value('mail_theme');
        if ($theme === null) {
            if (method_exists($GLOBALS['FORUM_DRIVER'], 'get_theme')) {
                $theme = $GLOBALS['FORUM_DRIVER']->get_theme(null, $member_id);
            }
        }
        if ($theme === null) {
            $theme = 'default';
        }
        if ((get_theme_option('capability_emails', null, $theme) == '0') || (in_array($theme, ['default', 'admin']))) { // Sucks, probably due to sending from Admin Zone...
            $theme = $GLOBALS['FORUM_DRIVER']->get_theme('', $member_id); // ... So get theme of welcome zone
        }
        if (get_theme_option('capability_emails', null, $theme) == '0') {
            $theme = 'default';
        }
    }

    /**
     * Log a message into the database.
     *
     * @param  boolean $queued Whether the message is to be queued rather than just logged
     * @param  string $subject_line The subject of the mail in plain text
     * @param  LONG_TEXT $message_raw The message, as Comcode
     * @param  array $to_emails The destination (recipient) e-mail addresses [array of strings]
     * @param  array $to_names The recipient names [array of strings]
     * @param  EMAIL $from_email The reply-to address (blank: site staff address)
     * @param  string $from_name The from name (blank: site name)
     * @return ?AUTO_LINK The queue ID (null: could not log)
     */
    protected function log_message(bool $queued, string $subject_line, string $message_raw, array $to_emails, array $to_names, string $from_email, string $from_name) : ?int
    {
        if (mt_rand(0, 100) == 1) {
            cms_register_shutdown_function_safe(function () {
                if (!$GLOBALS['SITE_DB']->table_is_locked('logged_mail_messages')) {
                    $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'logged_mail_messages WHERE m_date_and_time<' . strval(time() - 60 * 60 * 24 * intval(get_option('email_log_store_time'))) . ' AND m_queued=0', 500/*to reduce lock times*/, 0, true); // Log it all for 2 weeks, then delete; Errors suppressed in case DB write access broken
                }
            });
        }

        return $GLOBALS['SITE_DB']->query_insert('logged_mail_messages', [
            'm_subject' => cms_mb_substr($subject_line, 0, 255),
            'm_message' => $message_raw,
            'm_to_email' => serialize($to_emails),
            'm_to_name' => serialize($to_names),
            'm_extra_cc_addresses' => serialize($this->extra_cc_addresses),
            'm_extra_bcc_addresses' => serialize($this->extra_bcc_addresses),
            'm_join_time' => $this->require_recipient_valid_since,
            'm_from_email' => $from_email,
            'm_from_name' => $from_name,
            'm_priority' => $this->priority,
            'm_attachments' => serialize($this->attachments),
            'm_no_cc' => $this->no_cc ? 1 : 0,
            'm_as_member' => $this->as,
            'm_as_admin' => $this->as_admin ? 1 : 0,
            'm_in_html' => $this->in_html ? 1 : 0,
            'm_date_and_time' => time(),
            'm_member_id' => get_member(),
            'm_url' => get_self_url_easy(true),
            'm_queued' => $queued ? 1 : 0,
            'm_template' => $this->mail_template,
            'm_sender_email' => ($this->sender_email === null) ? '' : $this->sender_email,
            'm_plain_subject' => $this->plain_subject ? 1 : 0,
        ], true, !$queued); // No errors if we don't NEED this to work
    }

    /**
     * Find whether the message is going through the queue rather than sent immediately.
     *
     * @return boolean Whether the message is going through the queue
     */
    protected function is_through_queue() : bool
    {
        $through_queue = (!$this->bypass_queue) && (((cron_installed()) && (get_option('mail_queue') === '1'))) || (get_option('mail_queue_debug') === '1');
        if ((!empty($this->attachments)) && (get_option('mail_queue_debug') === '0')) {
            foreach (array_keys($this->attachments) as $path) {
                if ((substr($path, 0, strlen(get_custom_file_base() . '/')) != get_custom_file_base() . '/') && (substr($path, 0, strlen(get_file_base() . '/')) != get_file_base() . '/')) {
                    $through_queue = false;
                }
            }
        }
        return $through_queue;
    }

    /**
     * Find whether the e-mailer is currently enabled.
     *
     * @return boolean Whether the e-mailer is currently enabled
     */
    protected function is_enabled() : bool
    {
        if (running_script('stress_test_loader')) {
            return false;
        }

        if ((isset($GLOBALS['SITE_INFO']['no_email_output'])) && ($GLOBALS['SITE_INFO']['no_email_output'] === '1')) {
            $this->log('DISABLED', 'no_email_output==1');

            return false;
        }

        return true;
    }

    /**
     * Find whether the dispatcher instance is capable of sending e-mails.
     *
     * @param  array $advanced_parameters List of advanced parameters
     * @return boolean Whether the dispatcher instance is capable of sending e-mails
     */
    public function is_dispatcher_available(array $advanced_parameters) : bool
    {
        return true;
    }

    /**
     * Inject List-Unsubscribe headers for an e-mail to a single address.
     *
     * @param EMAIL $email_address The recipient e-mail address
     * @param string $_headers The provisional headers to use
     * @return string Updated headers to use including List-Unsubscribe
     */
    public function inject_unsubscribe_headers(string $email_address, string $_headers) : string
    {
        $list_unsubscribe_target = get_option('list_unsubscribe_target');
        $list_unsubscribe_post = get_option('list_unsubscribe_post');

        $headers = $_headers;
        if (!empty($list_unsubscribe_target)) {
            if (strpos($list_unsubscribe_target, 'mailto:') !== 0) { // mailto does not allow POSTing
                // Add recipient e-mail to POST data so we know who is unsubscribing
                if ($list_unsubscribe_post != '') {
                    $list_unsubscribe_post .= '&';
                }
                $list_unsubscribe_post .= 'email_address=' . rawurlencode($email_address);

                if ($list_unsubscribe_target == '1') { // Use the software's built-in List-Unsubscribe
                    $list_unsubscribe_target = find_script('unsubscribe');

                    require_code('crypt');

                    // Add a nonce (we cannot use CSRF because the member sending the email is not necessarily the one unsubscribing)
                    $nonce = get_secure_random_string();
                    $list_unsubscribe_post .= '&nonce=' . rawurlencode($nonce);

                    // Add a checksum ratchet using the e-mail address, nonce, and site salt
                    $list_unsubscribe_post .= '&checksum=' . rawurlencode(ratchet_hash($nonce . $email_address, get_site_salt()));
                }

                $headers .= 'List-Unsubscribe-Post: ' . $list_unsubscribe_post . $this->line_term;
            }

            $headers .= 'List-Unsubscribe: <' . $list_unsubscribe_target . '>' . $this->line_term;
        }

        return $headers;
    }

    /**
     * Download a URL, for use as an inline mail image.
     *
     * @param  URLPATH $img URL
     * @param  ?MEMBER $as Convert Comcode->tempcode as this member (a privilege thing: we don't want people being able to use admin rights by default!) (null: guest)
     * @param  integer $total_filesize Reference to where total filesize is being held
     * @return ?array A tuple: Mime type filename, file contents (null: error)
     */
    protected function get_image_for_cid(string $img, ?int $as, int &$total_filesize) : ?array
    {
        $file_path_stub = convert_url_to_path($img);
        $mime_type = get_mime_type(get_file_extension($img), has_privilege($as, 'comcode_dangerous'));
        $filename = preg_replace('#\?\d+$#', '', basename($img));
        if ($file_path_stub !== null) {
            $total_filesize += @filesize($file_path_stub);
            if ($total_filesize > 1024 * 1024 * 5) {
                return null; // Too large to process into an e-mail
            }

            $file_contents = @cms_file_get_contents_safe($file_path_stub, FILE_READ_LOCK);
        } else {
            $file_contents = null;
            $matches = [];
            require_code('attachments');
            if ((preg_match('#^' . preg_quote(find_script('attachment'), '#') . '\?id=(\d+)&amp;thumb=(0|1)#', $img, $matches) != 0) && (strpos($img, 'forum_db=1') === false)) {
                $rows = $GLOBALS['SITE_DB']->query_select('attachments', ['*'], ['id' => intval($matches[1])], 'ORDER BY a_add_time DESC');
                require_code('attachments');
                if ((array_key_exists(0, $rows)) && (has_attachment_access($as, intval($matches[1])))) {
                    $myrow = $rows[0];

                    if ($matches[2] == '1') {
                        $full = $myrow['a_thumb_url'];
                    } else {
                        $full = $myrow['a_url'];
                    }

                    if (url_is_local($full)) {
                        $_full = get_custom_file_base() . '/' . rawurldecode($full);
                        if (file_exists($_full)) {
                            $filename = $myrow['a_original_filename'];
                            require_code('mime_types');
                            $total_filesize += @filesize($_full);
                            if ($total_filesize > 1024 * 1024 * 5) {
                                return null; // Too large to process into an e-mail
                            }
                            $file_contents = cms_file_get_contents_safe($_full, FILE_READ_LOCK);
                            $mime_type = get_mime_type(get_file_extension($filename), has_privilege($as, 'comcode_dangerous'));
                        }
                    }
                }
            }
            if ($file_contents === null) {
                $http_result = cms_http_request($img, ['trigger_error' => false, 'byte_limit' => 1024 * 1024 * 5]);
                $file_contents = $http_result->data;
                if ($file_contents === null) {
                    return null;
                }
                $total_filesize += strlen($file_contents);
                if ($total_filesize > 1024 * 1024 * 5) {
                    return null; // Too large to process into an e-mail
                }
                if ($http_result->download_mime_type !== null) {
                    $mime_type = $http_result->download_mime_type;
                }
                if ($http_result->filename !== null) {
                    $filename = $http_result->filename;
                }
            }
        }

        return [$mime_type, $filename, $file_contents];
    }

    /**
     * Replace an HTML img tag such that it is CIDd. Callback for preg_replace_callback.
     *
     * @param  array $matches Matches
     * @return string Replacement
     *
     * @ignore
     */
    protected function mail_img_rep_callback(array $matches) : string
    {
        if ((!url_is_local($matches[0])) && (substr($matches[2], 0, strlen(get_custom_base_url())) != get_custom_base_url()) && (substr($matches[2], 0, strlen(get_base_url())) != get_base_url())) {
            return $matches[0];
        }

        $cid = uniqid('', true) . '@' . str_replace(' ', '_', get_domain()); // str_replace is in case someone has put in the domain wrong
        $this->cid_attachments_url_mapping[$cid] = $matches[2];
        return '<img ' . $matches[1] . 'src="cid:' . $cid . '"';
    }

    /**
     * Replace CSS image references such that it is CIDed. Callback for preg_replace_callback.
     *
     * @param  array $matches Matches
     * @return string Replacement
     *
     * @ignore
     */
    protected function mail_css_rep_callback(array $matches) : string
    {
        $filename = preg_replace('#\?\d+$#', '', basename($matches[1]));
        if (($filename != 'email_link.svg') && ($filename != 'external_link.svg')) {
            /*CSS CIDs do not work with Thunderbird, but data does
            $cid = uniqid('', true) . '@' . get_domain();
            $this->cid_attachments_url_mapping[$cid] = $matches[1];
            return 'url(\'cid:' . $cid . '\')';*/

            $total_filesize = 0;
            $test = $this->get_image_for_cid($matches[1], $GLOBALS['FORUM_DRIVER']->get_guest_id(), $total_filesize);
            if (($test === null) || $total_filesize > 1024 * 50/*Let's be reasonable here*/) {
                return 'none';
            }
            list($mime_type, $filename, $file_contents) = $test;

            $value = 'data:' . get_mime_type(get_file_extension($filename), false) . ';base64,' . base64_encode($file_contents);
            return 'url(\'data:' . $value . '\')';
        }
        return 'none';
    }

    /**
     * Log something (to the class's internal logging, which can be accessed by the caller).
     *
     * @param  string $verb Log verb (category of what is happening)
     * @param  string $message Log message
     */
    protected function log(string $verb, string $message)
    {
        $this->log .= '<p><strong>' . escape_html($verb) . '</strong>:<br />' . static_evaluate_tempcode(with_whitespace($message)) . '</p>';
    }
}

/**
 * Filter out any CSS selector blocks from the given CSS if they definitely do not affect the given (X)HTML.
 * While this is a clever algorithm, it isn't so clever as to actually try and match each selector against a DOM tree. If any segment of a compound selector matches, match is assumed.
 *
 * @param  ID_TEXT $c CSS file
 * @param  ?ID_TEXT $theme Theme (null: default)
 * @param  string $context (X) HTML context under which CSS is filtered
 * @return string Filtered CSS
 */
function filter_css(string $c, ?string $theme, string $context) : string
{
    if (($theme === null)) {
        $theme = $GLOBALS['FORUM_DRIVER']->get_theme();
    }

    // Reduce input parameters to critical components, and cache on - saves a lot of time if multiple e-mails sent by script
    static $cache = [];
    $simple_sig = preg_replace('#\s+(?!class)(?!id)[\w\-]+="[^"<>]*"#', '', preg_replace('#[^<>]*(<[^<>]+>)[^<>]*#s', '${1}', $context));
    $simple_sig .= $c . $theme;
    if (isset($cache[$simple_sig])) {
        return $cache[$simple_sig];
    }

    $_css = do_template($c, [], user_lang(), true/*can't fail on this error because it could be an e-mail from queue, with different addon state*/, null, '.css', 'css', $theme);
    $css = $_css->evaluate();

    if ($c == 'email') {
        return $css; // No filtering for this file
    }

    // Find out all our IDs
    $ids = [];
    $matches = [];
    $count = preg_match_all('#\sid=["\']([^"\']*)["\']#', $context, $matches);
    for ($i = 0; $i < $count; $i++) {
        $ids[$matches[1][$i]] = true;
    }

    // Find out all our classes
    $classes = [];
    $count = preg_match_all('#\sclass=["\']([^"\']*)["\']#', $context, $matches);
    for ($i = 0; $i < $count; $i++) {
        if ($matches[1][$i] == '') {
            continue;
        }
        $classes = array_merge($classes, preg_split('#\s+#', $matches[1][$i], -1, PREG_SPLIT_NO_EMPTY));
    }
    $classes = array_flip($classes);

    // Find all our XHTML tags
    $tags = [
        'html' => true,
        'body' => true,
    ];
    $count = preg_match_all('#<(\w+)([^\w])#', $context, $matches);
    for ($i = 0; $i < $count; $i++) {
        $tags[$matches[1][$i]] = true;
    }

    // Strip comments from CSS. This optimises, and also avoids us needing to do a sophisticated parse
    $css = preg_replace('#/\*.*\*/#Us', '', $css);

    // Strip all media rules, we don't support parsing it, and e-mails will not be that complex
    $middle_regexp = '[^\{\}]*' . '\{[^\{\}]*\}' . '[^\{\}]*';
    $css = preg_replace('#@media[^\{\}]*\{(' . $middle_regexp . ')*\}#s', '', $css);

    // Find and process each CSS selector block
    $stack = [];
    $css_new = '';
    $last_pos = 0;
    do {
        $pos1 = strpos($css, '{', $last_pos);
        $pos2 = strpos($css, '}', $last_pos);
        if (($pos1 === false) && ($pos2 === false)) {
            break;
        }

        if (($pos1 === false) || (($pos2 !== false) && ($pos2 < $pos1))) {
            if (!empty($stack)) {
                $start = array_pop($stack);
                if (empty($stack)) { // We've finished a top-level section
                    $real_start = strrpos(substr($css, 0, $start), '}');
                    $real_start = ($real_start === false) ? 0 : ($real_start + 1);
                    $selectors = explode(',', trim(substr($css, $real_start, $start - $real_start)));
                    $applies = false;
                    foreach ($selectors as $selector) {
                        $selector = trim($selector);

                        if (strpos($selector, '::selection') !== false) {
                            continue;
                        }
                        if (strpos($selector, 'a[href^="mailto:"]') !== false) {
                            continue;
                        }
                        if (strpos($selector, 'a[target="_blank"]') !== false) {
                            continue;
                        }

                        $matches = [];

                        // ID selectors
                        $num_matches = preg_match_all('#\#(\w+)#', $selector, $matches);
                        for ($i = 0; $i < $num_matches; $i++) {
                            if (!isset($ids[$matches[1][$i]])) {
                                continue 2;
                            }
                        }

                        // Class name selectors
                        $num_matches = preg_match_all('#\.(\w+)#', $selector, $matches);
                        for ($i = 0; $i < $num_matches; $i++) {
                            if (!isset($classes[$matches[1][$i]])) {
                                continue 2;
                            }
                        }

                        // Tag selectors
                        $num_matches = preg_match_all('#(^|\s|>)(\w+)#', $selector, $matches);
                        for ($i = 0; $i < $num_matches; $i++) {
                            if (!isset($tags[$matches[2][$i]])) {
                                continue 2;
                            }
                        }

                        $applies = true;
                        break;
                    }
                    if ($applies) {
                        $css_new .= trim(substr($css, $real_start, $pos2 - $real_start + 1)) . "\n\n"; // Append section
                    }
                }
            } else {
                //return $css; // Parsing error, extra close
                // But actually it's best we let it continue on
            }
            $last_pos = $pos2 + 1;
        } else {
            array_push($stack, $pos1);
            $last_pos = $pos1 + 1;
        }
    } while (true);

    $cache[$simple_sig] = $css_new;

    return $css_new;
}
