Index: trunk/lib/Email.inc.php
===================================================================
--- trunk/lib/Email.inc.php	(revision 770)
+++ trunk/lib/Email.inc.php	(revision 774)
@@ -61,5 +61,5 @@
         'from' => null,
         'subject' => null,
-        'headers' => null,
+        'headers' => [],
         'envelope_sender_address' => null, // AKA the bounce-to address. Will default to 'from' if left null.
         'regex' => null,
@@ -86,4 +86,8 @@
     // String that contains the email body after replacements.
     protected $_template_replaced;
+
+    // String that contains the final email body.
+    // Use only when email is manually composed and encoded outside of this Email class, and need to bypass replacements, detection of mail header injection attacks, and the encoding provided by mb_send_mail().
+    protected $_raw_body;
 
     // Email debug modes.
@@ -175,4 +179,23 @@
     }
 
+    /*
+    * Append headers to $this->_params['headers']
+    *
+    * @access   public
+    * @param    array   $headers    Array of key=>val pairs that will be appended.
+    * @return   void
+    * @author   Quinn Comendant <quinn@strangecode.com>
+    * @since    23 Jul 2022 13:36:21
+    */
+    public function appendHeaders($headers)
+    {
+        $app =& App::getInstance();
+        if (!isset($headers) || !is_array($headers)) {
+            $app->logMsg(sprintf('%s requires an array of header values', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
+        }
+
+        $this->_params['headers'] = array_merge($this->_params['headers'], $headers);
+    }
+
     /**
      * Loads template from file to generate email body.
@@ -188,8 +211,10 @@
 
         // Load file, using include_path.
-        if (!$this->_template = file_get_contents($template, true)) {
-            $app->logMsg(sprintf('Email template file does not exist: %s', $template), LOG_ERR, __FILE__, __LINE__);
+        $this->_template = file_get_contents($template, true);
+        if (!$this->_template || '' == trim($this->_template)) {
+            $app->logMsg(sprintf('Email template file does not exist or is empty: %s', $template), LOG_ERR, __FILE__, __LINE__);
             $this->_template = null;
             $this->_template_replaced = null;
+            $this->_raw_body = null;
             return false;
         }
@@ -203,4 +228,5 @@
         // This could be a new template, so reset the _template_replaced.
         $this->_template_replaced = null;
+        $this->_raw_body = null;
 
         $this->_template_filename = $template;
@@ -213,5 +239,5 @@
      *
      * @access  public
-     * @param   string  $template   Filename of email template.
+     * @param   string  $string   Content of email template.
      * @author  Quinn Comendant <quinn@strangecode.com>
      * @since   28 Nov 2005 12:56:23
@@ -222,6 +248,7 @@
 
         if ('' == trim($string)) {
-            $app->logMsg(sprintf('Empty string provided.', null), LOG_ERR, __FILE__, __LINE__);
+            $app->logMsg(sprintf('Empty string provided to %s.', __METHOD__), LOG_ERR, __FILE__, __LINE__);
             $this->_template_replaced = null;
+            $this->_raw_body = null;
             return false;
         } else {
@@ -229,4 +256,5 @@
             // This could be a new template, so reset the _template_replaced.
             $this->_template_replaced = null;
+            $this->_raw_body = null;
 
             $this->_template_filename = '(using Email::setString)';
@@ -271,4 +299,33 @@
         // Search and replace all values at once.
         $this->_template_replaced = str_replace($search, $replace, $this->_template);
+    }
+
+    /**
+     * Set the final email body to send. Using this disables any further post-processing, including encoding and scanning for mail header injection attacks.
+     *
+     * @access  public
+     * @param   string  $body   Final email body.
+     * @author  Quinn Comendant <quinn@strangecode.com>
+     * @since   23 Jul 2022 12:01:52
+     */
+    public function setRawBody($string)
+    {
+        $app =& App::getInstance();
+
+        if ('' == trim($string)) {
+            $app->logMsg(sprintf('Empty string provided to %s.', __METHOD__), LOG_ERR, __FILE__, __LINE__);
+            $this->_template = null;
+            $this->_template_replaced = null;
+            $this->_raw_body = null;
+            return false;
+        } else {
+            $this->_template = null;
+            $this->_template_replaced = null;
+            $this->_raw_body = $string;
+
+            $this->_template_filename = '(using Email::setRawBody)';
+
+            return true;
+        }
     }
 
@@ -290,4 +347,8 @@
         $app =& App::getInstance();
 
+        if (isset($this->_raw_body)) {
+            return $this->_raw_body;
+        }
+
         $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template;
         // Ensure all placeholders have been replaced. Find anything with {...} characters.
@@ -332,6 +393,6 @@
             $app->logMsg('Cannot send email. SUBJECT not defined.', LOG_ERR, __FILE__, __LINE__);
             return false;
-        } else if (!isset($this->_template)) {
-            $app->logMsg(sprintf('Cannot send email: "%s". Template not set.', $this->_params['subject']), LOG_ERR, __FILE__, __LINE__);
+        } else if (!isset($this->_template) && !isset($this->_raw_body)) {
+            $app->logMsg(sprintf('Cannot send email: "%s". Need a template or raw body.', $this->_params['subject']), LOG_ERR, __FILE__, __LINE__);
             return false;
         } else if (!isset($this->_params['to'])) {
@@ -343,32 +404,17 @@
         }
 
-        // Wrap email text body, using _template_replaced if replacements have been used, or just a fresh _template if not.
-        $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template;
-        if (false !== $this->getParam('wrap')) {
-            $final_body = wordwrap($final_body, $this->getParam('line_length'), $this->getParam('crlf'));
-        }
-
-        // Ensure all placeholders have been replaced. Find anything with {...} characters.
-        if (preg_match('/({[^}]+})/', $final_body, $unreplaced_match)) {
-            unset($unreplaced_match[0]);
-            $app->logMsg(sprintf('Unreplaced variable(s) "%s" in template "%s"', getDump($unreplaced_match), $this->_template_filename), LOG_ERR, __FILE__, __LINE__);
-            return false;
-        }
-
-        // Final "to" header can have multiple addresses if in an array.
+        // Final âtoâ header can have multiple addresses if in an array.
         $final_to = is_array($this->_params['to']) ? join(', ', $this->_params['to']) : $this->_params['to'];
-
-        // From headers are custom headers.
-        $headers = array('From' => $this->_params['from']);
-
-        // Additional headers.
-        if (isset($this->_params['headers']) && is_array($this->_params['headers'])) {
-            $headers = array_merge($this->_params['headers'], $headers);
-        }
+        if (!preg_match('/(?:>$|>,)/', $final_to)) {
+            $app->logMsg(sprintf('Email addresses should be enclosed in <angle brackets>: %s', $final_to), LOG_NOTICE, __FILE__, __LINE__);
+        }
+
+        // âFromâ headers are custom headers.
+        $this->appendHeaders(['From' => $this->_params['from']]);
 
         // Process headers.
         $final_headers_arr = array();
         $final_headers = '';
-        foreach ($headers as $key => $val) {
+        foreach ($this->_params['headers'] as $key => $val) {
             // Validate key and values.
             if (!isset($val) || !strlen($val)) {
@@ -376,5 +422,8 @@
                 continue;
             }
-            if (!strlen($key) || preg_match("/[\n\r]/", $key . $val) || preg_match('/[^\w-]/', $key)) {
+            // Ensure headers meet RFC requirements.
+            // https://datatracker.ietf.org/doc/html/rfc5322#section-2.1.1
+            // https://datatracker.ietf.org/doc/html/rfc5322#section-2.2
+            if (!strlen($key) || strlen($key . $val) > 998 || preg_match('/[^\x21-\x39\x3B-\x7E]/', $key)) {
                 $app->logMsg(sprintf('Broken email header provided: %s=%s', $key, $val), LOG_WARNING, __FILE__, __LINE__);
                 continue;
@@ -407,9 +456,29 @@
         }
 
-        // Check for mail header injection attacks.
-        $full_mail_content = join($this->getParam('crlf'), array($final_to, $this->_params['subject'], $final_body));
-        if (preg_match("/(^|[\n\r])(Content-Type|MIME-Version|Content-Transfer-Encoding|Bcc|Cc)\s*:/i", $full_mail_content)) {
-            $app->logMsg(sprintf('Mail header injection attack in content: %s', $full_mail_content), LOG_WARNING, __FILE__, __LINE__);
-            return false;
+        if (isset($this->_raw_body)) {
+            $mail_function = 'mail';
+            $final_body = $this->_raw_body;
+        } else {
+            $mail_function = 'mb_send_mail';
+
+            // Wrap email text body, using _template_replaced if replacements have been used, or just a fresh _template if not.
+            $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template;
+            if (false !== $this->getParam('wrap')) {
+                $final_body = wordwrap($final_body, $this->getParam('line_length'), $this->getParam('crlf'));
+            }
+
+            // Ensure all placeholders have been replaced. Find anything with {...} characters.
+            if (preg_match('/({[^}]+})/', $final_body, $unreplaced_match)) {
+                unset($unreplaced_match[0]);
+                $app->logMsg(sprintf('Unreplaced variable(s) "%s" in template "%s"', getDump($unreplaced_match), $this->_template_filename), LOG_ERR, __FILE__, __LINE__);
+                return false;
+            }
+
+            // Check for mail header injection attacks.
+            $full_mail_content = join($this->getParam('crlf'), array($final_to, $this->_params['subject'], $final_body));
+            if (preg_match("/(^|[\n\r])(Content-Type|MIME-Version|Content-Transfer-Encoding|Bcc|Cc)\s*:/i", $full_mail_content)) {
+                $app->logMsg(sprintf('Mail header injection attack in content: %s', $full_mail_content), LOG_WARNING, __FILE__, __LINE__);
+                return false;
+            }
         }
 
@@ -440,7 +509,7 @@
         // Send email without 5th parameter if safemode is enabled.
         if (ini_get('safe_mode')) {
-            $ret = mb_send_mail($final_to, $this->_params['subject'], $final_body, $final_headers);
-        } else {
-            $ret = mb_send_mail($final_to, $this->_params['subject'], $final_body, $final_headers, $additional_parameter);
+            $ret = $mail_function($final_to, $this->_params['subject'], $final_body, $final_headers);
+        } else {
+            $ret = $mail_function($final_to, $this->_params['subject'], $final_body, $final_headers, $additional_parameter);
         }
 
