source: trunk/lib/Email.inc.php @ 774

Last change on this file since 774 was 774, checked in by anonymous, 21 months ago

Add Email::setRawBody() for messages 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().

File size: 23.6 KB
Line 
1<?php
2/**
3 * The Strangecode Codebase - a general application development framework for PHP
4 * For details visit the project site: <http://trac.strangecode.com/codebase/>
5 * Copyright 2001-2012 Strangecode, LLC
6 *
7 * This file is part of The Strangecode Codebase.
8 *
9 * The Strangecode Codebase is free software: you can redistribute it and/or
10 * modify it under the terms of the GNU General Public License as published by the
11 * Free Software Foundation, either version 3 of the License, or (at your option)
12 * any later version.
13 *
14 * The Strangecode Codebase is distributed in the hope that it will be useful, but
15 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17 * details.
18 *
19 * You should have received a copy of the GNU General Public License along with
20 * The Strangecode Codebase. If not, see <http://www.gnu.org/licenses/>.
21 */
22
23/*
24* Email.inc.php
25*
26* Easy email template usage.
27*
28* @author  Quinn Comendant <quinn@strangecode.com>
29* @version 1.0
30*
31* Example of use:
32---------------------------------------------------------------------
33// Setup email object.
34$email = new Email(array(
35    'to' => array($frm['email'], 'q@lovemachine.local'),
36    'from' => sprintf('"%s" <%s>', addcslashes($app->getParam('site_name'), '"'), $app->getParam('site_email')),
37    'subject' => 'Your account has been activated',
38));
39$email->setTemplate('email_registration_confirm.eml');
40// $email->setString('Or you can pass your message body as a string, also with {VARIABLES}.');
41$email->replace(array(
42    'site_name' => $app->getParam('site_name'),
43    'site_url' => $app->getParam('site_url'),
44    'username' => $frm['username'],
45    'password' => $frm['password1'],
46));
47if ($email->send()) {
48    $app->raiseMsg(sprintf(_("A confirmation email has been sent to %s."), $frm['email']), MSG_SUCCESS, __FILE__, __LINE__);
49} else {
50    $app->logMsg(sprintf('Error sending confirmation email to address %s', $frm['email']), LOG_NOTICE, __FILE__, __LINE__);
51}
52---------------------------------------------------------------------
53*/
54
55class Email
56{
57
58    // Default parameters, to be overwritten by setParam() and read with getParam()
59    protected $_params = array(
60        'to' => null,
61        'from' => null,
62        'subject' => null,
63        'headers' => [],
64        'envelope_sender_address' => null, // AKA the bounce-to address. Will default to 'from' if left null.
65        'regex' => null,
66
67        // A single carriage return (\n) should terminate lines for locally injected mail.
68        // A carriage return + line-feed (\r\n) should be used if sending mail directly with SMTP.
69        'crlf' => "\n",
70
71        // RFC 2822 says line length MUST be no more than 998 characters, and SHOULD be no more than 78 characters, excluding the CRLF.
72        // http://mailformat.dan.info/body/linelength.html
73        'wrap' => true,
74        'line_length' => 75,
75
76        'sandbox_mode' => null,
77        'sandbox_to_addr' => null,
78    );
79
80    // String that contains filename of the template used (for logging).
81    protected $_template_filename;
82
83    // String that contains the email body.
84    protected $_template;
85
86    // String that contains the email body after replacements.
87    protected $_template_replaced;
88
89    // String that contains the final email body.
90    // 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().
91    protected $_raw_body;
92
93    // Email debug modes.
94    const SANDBOX_MODE_REDIRECT = 1; // Send all mail to 'sandbox_to_addr'
95    const SANDBOX_MODE_STDERR = 2; // Log all mail to stderr
96    const SANDBOX_MODE_LOG = 3; // Log all mail using $app->logMsg(
)
97
98    /**
99     * Constructor.
100     *
101     * @access  public
102     * @param   array   $params     Array of object parameters.
103     * @author  Quinn Comendant <quinn@strangecode.com>
104     * @since   28 Nov 2005 12:59:41
105     */
106    public function __construct($params=null)
107    {
108        $app =& App::getInstance();
109
110        // The regex used in validEmail(). Set here instead of in the default _params above so we can use the concatenation . dot.
111        // This matches a (valid) email address as complex as:
112        //      "Jane & Bob Smith" <bob&smith's/dep=sales!@smith-wick.ca.us > (Sales department)
113        // ...and something as simple as:
114        //      x@x.com
115        $this->setParam(array('regex' => '/^(?:(?:"[^"]*?"\s*|[^,@]*)(<\s*)|(?:"[^"]*?"|[^,@]*)\s+|)'   // Display name
116        . '((?:[^.<>\s@",\[\]]+[^<>\s@",\[\]])*[^.<>\s@",\[\]]+)'       // Local-part
117        . '@'                                                           // @
118        . '((?:(\[)|[a-z0-9]?)'                                         // Domain, first char
119        . '(?(4)'                                                       // Domain conditional for if first domain char is [
120        . '(?:[0-9]{1,3}\.){3}[0-9]{1,3}\]'                             // TRUE, matches IP address
121        . '|'
122        . '[.-]?(?:[a-z0-9]+[-.])*(?:[a-z0-9]+\.)+[a-z]{2,19}))'        // FALSE, matches domain name
123        . '(?(1)'                                                       // Comment conditional for if initial < exists
124        . '(?:\s*>\s*|>\s+\([^,@]+\)\s*)'                               // TRUE, ensure ending >
125        . '|'
126        . '(?:|\s*|\s+\([^,@]+\)\s*))$/i' . $app->getParam('preg_u'))); // FALSE ensure there is no ending >
127
128        if (isset($params)) {
129            $this->setParam($params);
130        }
131    }
132
133    /**
134     * Set (or overwrite existing) parameters by passing an array of new parameters.
135     *
136     * @access public
137     * @param  array    $params     Array of parameters (key => val pairs).
138     */
139    public function setParam($params)
140    {
141        $app =& App::getInstance();
142
143        if (isset($params) && is_array($params)) {
144            // Enforce valid email addresses.
145            if (isset($params['to']) && !$this->validEmail($params['to'])) {
146                $params['to'] = null;
147            }
148            if (isset($params['from']) && !$this->validEmail($params['from'])) {
149                $params['from'] = null;
150            }
151            if (isset($params['envelope_sender_address']) && !$this->validEmail($params['envelope_sender_address'])) {
152                $params['envelope_sender_address'] = null;
153            }
154
155            // Merge new parameters with old overriding only those passed.
156            $this->_params = array_merge($this->_params, $params);
157        } else {
158            $app->logMsg(sprintf('Parameters are not an array: %s', $params), LOG_ERR, __FILE__, __LINE__);
159        }
160    }
161
162    /**
163     * Return the value of a parameter, if it exists.
164     *
165     * @access public
166     * @param string $param        Which parameter to return.
167     * @return mixed               Configured parameter value.
168     */
169    public function getParam($param)
170    {
171        $app =& App::getInstance();
172
173        if (array_key_exists($param, $this->_params)) {
174            return $this->_params[$param];
175        } else {
176            $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__);
177            return null;
178        }
179    }
180
181    /*
182    * Append headers to $this->_params['headers']
183    *
184    * @access   public
185    * @param    array   $headers    Array of key=>val pairs that will be appended.
186    * @return   void
187    * @author   Quinn Comendant <quinn@strangecode.com>
188    * @since    23 Jul 2022 13:36:21
189    */
190    public function appendHeaders($headers)
191    {
192        $app =& App::getInstance();
193        if (!isset($headers) || !is_array($headers)) {
194            $app->logMsg(sprintf('%s requires an array of header values', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
195        }
196
197        $this->_params['headers'] = array_merge($this->_params['headers'], $headers);
198    }
199
200    /**
201     * Loads template from file to generate email body.
202     *
203     * @access  public
204     * @param   string  $template   Filename of email template.
205     * @author  Quinn Comendant <quinn@strangecode.com>
206     * @since   28 Nov 2005 12:56:23
207     */
208    public function setTemplate($template)
209    {
210        $app =& App::getInstance();
211
212        // Load file, using include_path.
213        $this->_template = file_get_contents($template, true);
214        if (!$this->_template || '' == trim($this->_template)) {
215            $app->logMsg(sprintf('Email template file does not exist or is empty: %s', $template), LOG_ERR, __FILE__, __LINE__);
216            $this->_template = null;
217            $this->_template_replaced = null;
218            $this->_raw_body = null;
219            return false;
220        }
221
222        // Ensure template is UTF-8.
223        $detected_encoding = mb_detect_encoding($this->_template, array('UTF-8', 'ISO-8859-1', 'WINDOWS-1252'), true);
224        if ('UTF-8' != strtoupper($detected_encoding)) {
225            $this->_template = mb_convert_encoding($this->_template, 'UTF-8', $detected_encoding);
226        }
227
228        // This could be a new template, so reset the _template_replaced.
229        $this->_template_replaced = null;
230        $this->_raw_body = null;
231
232        $this->_template_filename = $template;
233
234        return true;
235    }
236
237    /**
238     * Loads template from string to generate email body.
239     *
240     * @access  public
241     * @param   string  $string   Content of email template.
242     * @author  Quinn Comendant <quinn@strangecode.com>
243     * @since   28 Nov 2005 12:56:23
244     */
245    public function setString($string)
246    {
247        $app =& App::getInstance();
248
249        if ('' == trim($string)) {
250            $app->logMsg(sprintf('Empty string provided to %s.', __METHOD__), LOG_ERR, __FILE__, __LINE__);
251            $this->_template_replaced = null;
252            $this->_raw_body = null;
253            return false;
254        } else {
255            $this->_template = $string;
256            // This could be a new template, so reset the _template_replaced.
257            $this->_template_replaced = null;
258            $this->_raw_body = null;
259
260            $this->_template_filename = '(using Email::setString)';
261
262            return true;
263        }
264    }
265
266    /**
267     * Replace variables in template with argument data.
268     *
269     * @access  public
270     * @param   array   $replacements   Array keys are the values to search for, array vales are the replacement values.
271     * @author  Quinn Comendant <quinn@strangecode.com>
272     * @since   28 Nov 2005 13:08:51
273     */
274    public function replace($replacements)
275    {
276        $app =& App::getInstance();
277
278        // Ensure template exists.
279        if (!isset($this->_template)) {
280            $app->logMsg(sprintf('Cannot replace variables, no template defined.', null), LOG_ERR, __FILE__, __LINE__);
281            return false;
282        }
283
284        // Ensure replacements argument is an array.
285        if (!is_array($replacements)) {
286            $app->logMsg(sprintf('Cannot replace variables, invalid replacements.', null), LOG_ERR, __FILE__, __LINE__);
287            return false;
288        }
289
290        // Apply regex pattern to search elements.
291        $search = array_keys($replacements);
292        array_walk($search, function (&$v) {
293            $v = sprintf('{%s}', mb_strtoupper($v));
294        });
295
296        // Replacement values.
297        $replace = array_values($replacements);
298
299        // Search and replace all values at once.
300        $this->_template_replaced = str_replace($search, $replace, $this->_template);
301    }
302
303    /**
304     * Set the final email body to send. Using this disables any further post-processing, including encoding and scanning for mail header injection attacks.
305     *
306     * @access  public
307     * @param   string  $body   Final email body.
308     * @author  Quinn Comendant <quinn@strangecode.com>
309     * @since   23 Jul 2022 12:01:52
310     */
311    public function setRawBody($string)
312    {
313        $app =& App::getInstance();
314
315        if ('' == trim($string)) {
316            $app->logMsg(sprintf('Empty string provided to %s.', __METHOD__), LOG_ERR, __FILE__, __LINE__);
317            $this->_template = null;
318            $this->_template_replaced = null;
319            $this->_raw_body = null;
320            return false;
321        } else {
322            $this->_template = null;
323            $this->_template_replaced = null;
324            $this->_raw_body = $string;
325
326            $this->_template_filename = '(using Email::setRawBody)';
327
328            return true;
329        }
330    }
331
332    /*
333    * Returns the body of the current email. This can be used to store the message that is being sent.
334    * It will use the original template, or the replaced template if it has been processed.
335    * You can also use this function to do post-processing on the email body before sending it,
336    * like removing extraneous lines:
337    * $email->setString(preg_replace('/(?:(?:\r\n|\r|\n)\s*){2}/su', "\n\n", $email->getBody()));
338    *
339    * @access   public
340    * @return   string  Message body.
341    * @author   Quinn Comendant <quinn@strangecode.com>
342    * @version  1.0
343    * @since    18 Nov 2014 21:15:19
344    */
345    public function getBody()
346    {
347        $app =& App::getInstance();
348
349        if (isset($this->_raw_body)) {
350            return $this->_raw_body;
351        }
352
353        $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template;
354        // Ensure all placeholders have been replaced. Find anything with {...} characters.
355        if (preg_match('/({[^}]+})/', $final_body, $unreplaced_match)) {
356            unset($unreplaced_match[0]);
357            $app->logMsg(sprintf('Cannot get email body. Unreplaced variable(s) "%s" in template "%s"', getDump($unreplaced_match), $this->_template_filename), LOG_ERR, __FILE__, __LINE__);
358            return false;
359        }
360        return $final_body;
361    }
362
363    /**
364     * Send email using PHP's mail() function.
365     *
366     * @access  public
367     * @param   string  $to
368     * @param   string  $from
369     * @param   string  $subject
370     * @author  Quinn Comendant <quinn@strangecode.com>
371     * @since   28 Nov 2005 12:56:09
372     */
373    public function send($to=null, $from=null, $subject=null, $headers=null)
374    {
375        $app =& App::getInstance();
376
377        // Use arguments if provided.
378        if (isset($to)) {
379            $this->setParam(array('to' => $to));
380        }
381        if (isset($from)) {
382            $this->setParam(array('from' => $from));
383        }
384        if (isset($subject)) {
385            $this->setParam(array('subject' => $subject));
386        }
387        if (isset($headers)) {
388            $this->setParam(array('headers' => $headers));
389        }
390
391        // Ensure required values exist.
392        if (!isset($this->_params['subject'])) {
393            $app->logMsg('Cannot send email. SUBJECT not defined.', LOG_ERR, __FILE__, __LINE__);
394            return false;
395        } else if (!isset($this->_template) && !isset($this->_raw_body)) {
396            $app->logMsg(sprintf('Cannot send email: "%s". Need a template or raw body.', $this->_params['subject']), LOG_ERR, __FILE__, __LINE__);
397            return false;
398        } else if (!isset($this->_params['to'])) {
399            $app->logMsg(sprintf('Cannot send email: "%s". TO not defined.', $this->_params['subject']), LOG_NOTICE, __FILE__, __LINE__);
400            return false;
401        } else if (!isset($this->_params['from'])) {
402            $app->logMsg(sprintf('Cannot send email: "%s". FROM not defined.', $this->_params['subject']), LOG_ERR, __FILE__, __LINE__);
403            return false;
404        }
405
406        // Final “to” header can have multiple addresses if in an array.
407        $final_to = is_array($this->_params['to']) ? join(', ', $this->_params['to']) : $this->_params['to'];
408        if (!preg_match('/(?:>$|>,)/', $final_to)) {
409            $app->logMsg(sprintf('Email addresses should be enclosed in <angle brackets>: %s', $final_to), LOG_NOTICE, __FILE__, __LINE__);
410        }
411
412        // “From” headers are custom headers.
413        $this->appendHeaders(['From' => $this->_params['from']]);
414
415        // Process headers.
416        $final_headers_arr = array();
417        $final_headers = '';
418        foreach ($this->_params['headers'] as $key => $val) {
419            // Validate key and values.
420            if (!isset($val) || !strlen($val)) {
421                $app->logMsg(sprintf('Empty email header provided: %s', $key), LOG_NOTICE, __FILE__, __LINE__);
422                continue;
423            }
424            // Ensure headers meet RFC requirements.
425            // https://datatracker.ietf.org/doc/html/rfc5322#section-2.1.1
426            // https://datatracker.ietf.org/doc/html/rfc5322#section-2.2
427            if (!strlen($key) || strlen($key . $val) > 998 || preg_match('/[^\x21-\x39\x3B-\x7E]/', $key)) {
428                $app->logMsg(sprintf('Broken email header provided: %s=%s', $key, $val), LOG_WARNING, __FILE__, __LINE__);
429                continue;
430            }
431            // If the envelope_sender_address was given as a header, move it to the correct place.
432            if ('envelope_sender_address' == strtolower($key)) {
433                $this->_params['envelope_sender_address'] = isset($this->_params['envelope_sender_address']) ? $this->_params['envelope_sender_address'] : $val;
434                continue;
435            }
436            // If we're sending in sandbox mode, remove any headers with recipient addresses.
437            if ($this->getParam('sandbox_mode') == self::SANDBOX_MODE_REDIRECT && in_array(strtolower($key), array('to', 'cc', 'bcc')) && mb_strpos($val, '@') !== false) {
438                // Don't carry this into the $final_headers.
439                $app->logMsg(sprintf('Skipping header in sandbox mode: %s=%s', $key, $val), LOG_DEBUG, __FILE__, __LINE__);
440                continue;
441            }
442            $final_headers_arr[] = sprintf('%s: %s', $key, $val);
443        }
444        $final_headers = join($this->getParam('crlf'), $final_headers_arr);
445
446        // This is the address where delivery problems are sent to. We must strip off everything except the local@domain part.
447        if (isset($this->_params['envelope_sender_address'])) {
448            $envelope_sender_address = sprintf('<%s>', trim($this->_params['envelope_sender_address'], '<>'));
449        } else {
450            $envelope_sender_address = preg_replace('/^.*<?([^\s@\[\]<>()]+\@[A-Za-z0-9.-]{1,}\.[A-Za-z]{2,19})>?$/iU' . $app->getParam('preg_u'), '$1', $this->_params['from']);
451        }
452        if ('' != $envelope_sender_address && $this->validEmail($envelope_sender_address)) {
453            $additional_parameter = sprintf('-f %s', $envelope_sender_address);
454        } else {
455            $additional_parameter = '';
456        }
457
458        if (isset($this->_raw_body)) {
459            $mail_function = 'mail';
460            $final_body = $this->_raw_body;
461        } else {
462            $mail_function = 'mb_send_mail';
463
464            // Wrap email text body, using _template_replaced if replacements have been used, or just a fresh _template if not.
465            $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template;
466            if (false !== $this->getParam('wrap')) {
467                $final_body = wordwrap($final_body, $this->getParam('line_length'), $this->getParam('crlf'));
468            }
469
470            // Ensure all placeholders have been replaced. Find anything with {...} characters.
471            if (preg_match('/({[^}]+})/', $final_body, $unreplaced_match)) {
472                unset($unreplaced_match[0]);
473                $app->logMsg(sprintf('Unreplaced variable(s) "%s" in template "%s"', getDump($unreplaced_match), $this->_template_filename), LOG_ERR, __FILE__, __LINE__);
474                return false;
475            }
476
477            // Check for mail header injection attacks.
478            $full_mail_content = join($this->getParam('crlf'), array($final_to, $this->_params['subject'], $final_body));
479            if (preg_match("/(^|[\n\r])(Content-Type|MIME-Version|Content-Transfer-Encoding|Bcc|Cc)\s*:/i", $full_mail_content)) {
480                $app->logMsg(sprintf('Mail header injection attack in content: %s', $full_mail_content), LOG_WARNING, __FILE__, __LINE__);
481                return false;
482            }
483        }
484
485        // Enter sandbox mode, if specified.
486        switch ($this->getParam('sandbox_mode')) {
487        case self::SANDBOX_MODE_REDIRECT:
488            if (!$this->getParam('sandbox_to_addr')) {
489                $app->logMsg(sprintf('Email sandbox_mode is SANDBOX_MODE_REDIRECT but sandbox_to_addr is not set.', null), LOG_ERR, __FILE__, __LINE__);
490                break;
491            }
492            $final_to = $this->getParam('sandbox_to_addr');
493            break;
494
495        case self::SANDBOX_MODE_STDERR:
496            file_put_contents('php://stderr', sprintf("Subject: %s\nTo: %s\n%s\n\n%s", $this->getParam('subject'), $final_to, str_replace($this->getParam('crlf'), "\n", $final_headers), $final_body), FILE_APPEND);
497            return true;
498
499        case self::SANDBOX_MODE_LOG:
500            // Temporarily modify log settings to allow full multi-line emails to appear in logs.
501            $log_serialize = $app->getParam('log_serialize');
502            $log_message_max_length = $app->getParam('log_message_max_length');
503            $app->setParam(array('log_serialize' => false, 'log_message_max_length' => 65536));
504            $app->logMsg(sprintf("\nSubject: %s\nTo: %s\n%s\n\n%s", $this->getParam('subject'), $final_to, trim(str_replace($this->getParam('crlf'), "\n", $final_headers)), trim($final_body)), LOG_DEBUG, __FILE__, __LINE__);
505            $app->setParam(array('log_serialize' => $log_serialize, 'log_message_max_length' => $log_message_max_length));
506            return true;
507        }
508
509        // Send email without 5th parameter if safemode is enabled.
510        if (ini_get('safe_mode')) {
511            $ret = $mail_function($final_to, $this->_params['subject'], $final_body, $final_headers);
512        } else {
513            $ret = $mail_function($final_to, $this->_params['subject'], $final_body, $final_headers, $additional_parameter);
514        }
515
516        // Ensure message was successfully accepted for delivery.
517        if ($ret) {
518            $app->logMsg(sprintf('Email successfully sent to %s: %s', $final_to, $this->_params['subject']), LOG_INFO, __FILE__, __LINE__);
519            return true;
520        } else {
521            $app->logMsg(sprintf('Email failure: %s, %s, %s, %s', $final_to, $this->_params['subject'], str_replace("\r\n", '\r\n', $final_headers), $additional_parameter), LOG_WARNING, __FILE__, __LINE__);
522            return false;
523        }
524    }
525
526    /**
527     * Validates an email address based on the recommendations in RFC 3696.
528     * Is more loose than restrictive, to allow the many valid variants of
529     * email addresses while catching the most common mistakes. Checks an array too.
530     * http://www.faqs.org/rfcs/rfc822.html
531     * http://www.faqs.org/rfcs/rfc2822.html
532     * http://www.faqs.org/rfcs/rfc3696.html
533     * http://www.faqs.org/rfcs/rfc1035.html
534     *
535     * @access  public
536     * @param   mixed  $email  Address to check, string or array.
537     * @return  bool    Validity of address.
538     * @author  Quinn Comendant <quinn@strangecode.com>
539     * @since   30 Nov 2005 22:00:50
540     */
541    public function validEmail($email)
542    {
543        $app =& App::getInstance();
544
545        // If an array, check values recursively.
546        if (is_array($email)) {
547            foreach ($email as $e) {
548                if (!$this->validEmail($e)) {
549                    return false;
550                }
551            }
552            return true;
553        } else {
554            // To be valid email address must match regex and fit within the length constraints.
555            if (preg_match($this->getParam('regex'), $email, $e_parts) && mb_strlen($e_parts[2]) < 64 && mb_strlen($e_parts[3]) < 255) {
556                return true;
557            } else {
558                $app->logMsg(sprintf('Invalid email address: %s', $email), LOG_INFO, __FILE__, __LINE__);
559                return false;
560            }
561        }
562    }
563}
564
Note: See TracBrowser for help on using the repository browser.