source: trunk/lib/Email.inc.php

Last change on this file was 813, checked in by anonymous, 5 weeks ago

Domains with multiple consecutive hyphens are valid

File size: 23.8 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(Array $params=[])
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(['regex' =>
116            '/^(?:(?:"[^"]*?"\s*|[^,@]*)(<\s*)|(?:"[^"]*?"|[^,@]*)\s+|)'        // Display name
117            . '((?:[^.<>\s@",\[\]]+[^<>\s@",\[\]])*[^.<>\s@",\[\]]+)'           // Local-part
118            . '@'                                                               // @
119            . '((?:(\[)|[a-z0-9]?)'                                             // Domain, first char
120            . '(?(4)'                                                           // Domain conditional for if first domain char is [
121            . '(?:[0-9]{1,3}\.){3}[0-9]{1,3}\]'                                 // TRUE, matches IP address
122            . '|'
123            . '(?:\.|-+)?(?:[a-z0-9]+(?:\.|-+)?)*(?:[a-z0-9]+\.)+[a-z]{2,19}))' // FALSE, matches domain name
124            . '(?(1)'                                                           // Comment conditional for if initial < exists
125            . '(?:\s*>\s*|>\s+\([^,@]+\)\s*)'                                   // TRUE, ensure ending >
126            . '|'
127            . '(?:|\s*|\s+\([^,@]+\)\s*))$/i' . $app->getParam('preg_u')        // FALSE ensure there is no ending >
128        ]);
129
130        if (isset($params)) {
131            $this->setParam($params);
132        }
133    }
134
135    /**
136     * Set (or overwrite existing) parameters by passing an array of new parameters.
137     *
138     * @access public
139     * @param  array    $params     Array of parameters (key => val pairs).
140     */
141    public function setParam($params)
142    {
143        $app =& App::getInstance();
144
145        if (isset($params) && is_array($params)) {
146            // Enforce valid email addresses.
147            if (isset($params['to']) && !$this->validEmail($params['to'])) {
148                $params['to'] = null;
149            }
150            if (isset($params['from']) && !$this->validEmail($params['from'])) {
151                $params['from'] = null;
152            }
153            if (isset($params['envelope_sender_address']) && !$this->validEmail($params['envelope_sender_address'])) {
154                $params['envelope_sender_address'] = null;
155            }
156
157            // Merge new parameters with old overriding only those passed.
158            $this->_params = array_merge($this->_params, $params);
159        } else {
160            $app->logMsg(sprintf('Parameters are not an array: %s', $params), LOG_ERR, __FILE__, __LINE__);
161        }
162    }
163
164    /**
165     * Return the value of a parameter, if it exists.
166     *
167     * @access public
168     * @param string $param        Which parameter to return.
169     * @return mixed               Configured parameter value.
170     */
171    public function getParam($param)
172    {
173        $app =& App::getInstance();
174
175        if (array_key_exists($param, $this->_params)) {
176            return $this->_params[$param];
177        } else {
178            $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__);
179            return null;
180        }
181    }
182
183    /*
184    * Append headers to $this->_params['headers']
185    *
186    * @access   public
187    * @param    array   $headers    Array of key=>val pairs that will be appended.
188    * @return   void
189    * @author   Quinn Comendant <quinn@strangecode.com>
190    * @since    23 Jul 2022 13:36:21
191    */
192    public function appendHeaders($headers)
193    {
194        $app =& App::getInstance();
195        if (!isset($headers) || !is_array($headers)) {
196            $app->logMsg(sprintf('%s requires an array of header values', __METHOD__), LOG_NOTICE, __FILE__, __LINE__);
197        }
198
199        $this->_params['headers'] = array_merge($this->_params['headers'], $headers);
200    }
201
202    /**
203     * Loads template from file to generate email body.
204     *
205     * @access  public
206     * @param   string  $template   Filename of email template.
207     * @author  Quinn Comendant <quinn@strangecode.com>
208     * @since   28 Nov 2005 12:56:23
209     */
210    public function setTemplate($template)
211    {
212        $app =& App::getInstance();
213
214        // Load file, using include_path.
215        $this->_template = file_get_contents($template, true);
216        if (!$this->_template || '' == trim($this->_template)) {
217            $app->logMsg(sprintf('Email template file does not exist or is empty: %s', $template), LOG_ERR, __FILE__, __LINE__);
218            $this->_template = null;
219            $this->_template_replaced = null;
220            $this->_raw_body = null;
221            return false;
222        }
223
224        // Ensure template is UTF-8.
225        $detected_encoding = mb_detect_encoding($this->_template, array('UTF-8', 'ISO-8859-1', 'WINDOWS-1252'), true);
226        if ('UTF-8' != strtoupper($detected_encoding)) {
227            $this->_template = mb_convert_encoding($this->_template, 'UTF-8', $detected_encoding);
228        }
229
230        // This could be a new template, so reset the _template_replaced.
231        $this->_template_replaced = null;
232        $this->_raw_body = null;
233
234        $this->_template_filename = $template;
235
236        return true;
237    }
238
239    /**
240     * Loads template from string to generate email body.
241     *
242     * @access  public
243     * @param   string  $string   Content of email template.
244     * @author  Quinn Comendant <quinn@strangecode.com>
245     * @since   28 Nov 2005 12:56:23
246     */
247    public function setString($string)
248    {
249        $app =& App::getInstance();
250
251        if ('' == trim($string)) {
252            $app->logMsg(sprintf('Empty string provided to %s.', __METHOD__), LOG_ERR, __FILE__, __LINE__);
253            $this->_template_replaced = null;
254            $this->_raw_body = null;
255            return false;
256        } else {
257            $this->_template = $string;
258            // This could be a new template, so reset the _template_replaced.
259            $this->_template_replaced = null;
260            $this->_raw_body = null;
261
262            $this->_template_filename = '(using Email::setString)';
263
264            return true;
265        }
266    }
267
268    /**
269     * Replace variables in template with argument data.
270     *
271     * @access  public
272     * @param   array   $replacements   Array keys are the values to search for, array vales are the replacement values.
273     * @author  Quinn Comendant <quinn@strangecode.com>
274     * @since   28 Nov 2005 13:08:51
275     */
276    public function replace($replacements)
277    {
278        $app =& App::getInstance();
279
280        // Ensure template exists.
281        if (!isset($this->_template)) {
282            $app->logMsg(sprintf('Cannot replace variables, no template defined.', null), LOG_ERR, __FILE__, __LINE__);
283            return false;
284        }
285
286        // Ensure replacements argument is an array.
287        if (!is_array($replacements)) {
288            $app->logMsg(sprintf('Cannot replace variables, invalid replacements.', null), LOG_ERR, __FILE__, __LINE__);
289            return false;
290        }
291
292        // Apply regex pattern to search elements.
293        $search = array_keys($replacements);
294        array_walk($search, function (&$v) {
295            $v = sprintf('{%s}', mb_strtoupper($v));
296        });
297
298        // Replacement values.
299        $replace = array_values($replacements);
300
301        // Search and replace all values at once.
302        $this->_template_replaced = str_replace($search, $replace, $this->_template);
303    }
304
305    /**
306     * Set the final email body to send. Using this disables any further post-processing, including encoding and scanning for mail header injection attacks.
307     *
308     * @access  public
309     * @param   string  $body   Final email body.
310     * @author  Quinn Comendant <quinn@strangecode.com>
311     * @since   23 Jul 2022 12:01:52
312     */
313    public function setRawBody($string)
314    {
315        $app =& App::getInstance();
316
317        if ('' == trim($string)) {
318            $app->logMsg(sprintf('Empty string provided to %s.', __METHOD__), LOG_ERR, __FILE__, __LINE__);
319            $this->_template = null;
320            $this->_template_replaced = null;
321            $this->_raw_body = null;
322            return false;
323        } else {
324            $this->_template = null;
325            $this->_template_replaced = null;
326            $this->_raw_body = $string;
327
328            $this->_template_filename = '(using Email::setRawBody)';
329
330            return true;
331        }
332    }
333
334    /*
335    * Returns the body of the current email. This can be used to store the message that is being sent.
336    * It will use the original template, or the replaced template if it has been processed.
337    * You can also use this function to do post-processing on the email body before sending it,
338    * like removing extraneous lines:
339    * $email->setString(preg_replace('/(?:(?:\r\n|\r|\n)\s*){2}/su', "\n\n", $email->getBody()));
340    *
341    * @access   public
342    * @return   string  Message body.
343    * @author   Quinn Comendant <quinn@strangecode.com>
344    * @version  1.0
345    * @since    18 Nov 2014 21:15:19
346    */
347    public function getBody()
348    {
349        $app =& App::getInstance();
350
351        if (isset($this->_raw_body)) {
352            return $this->_raw_body;
353        }
354
355        $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template;
356        // Ensure all placeholders have been replaced. Find anything with {...} characters.
357        if (preg_match('/({[^}]+})/', $final_body, $unreplaced_match)) {
358            unset($unreplaced_match[0]);
359            $app->logMsg(sprintf('Cannot get email body. Unreplaced variable(s) "%s" in template "%s"', getDump($unreplaced_match), $this->_template_filename), LOG_ERR, __FILE__, __LINE__);
360            return false;
361        }
362        return $final_body;
363    }
364
365    /**
366     * Send email using PHP's mail() function.
367     *
368     * @access  public
369     * @param   string  $to
370     * @param   string  $from
371     * @param   string  $subject
372     * @author  Quinn Comendant <quinn@strangecode.com>
373     * @since   28 Nov 2005 12:56:09
374     */
375    public function send($to=null, $from=null, $subject=null, $headers=null)
376    {
377        $app =& App::getInstance();
378
379        // Use arguments if provided.
380        if (isset($to)) {
381            $this->setParam(array('to' => $to));
382        }
383        if (isset($from)) {
384            $this->setParam(array('from' => $from));
385        }
386        if (isset($subject)) {
387            $this->setParam(array('subject' => $subject));
388        }
389        if (isset($headers)) {
390            $this->setParam(array('headers' => $headers));
391        }
392
393        // Ensure required values exist.
394        if (!isset($this->_params['subject'])) {
395            $app->logMsg('Cannot send email. SUBJECT not defined.', LOG_ERR, __FILE__, __LINE__);
396            return false;
397        } else if (!isset($this->_template) && !isset($this->_raw_body)) {
398            $app->logMsg(sprintf('Cannot send email: "%s". Need a template or raw body.', $this->_params['subject']), LOG_ERR, __FILE__, __LINE__);
399            return false;
400        } else if (!isset($this->_params['to'])) {
401            $app->logMsg(sprintf('Cannot send email: "%s". TO not defined.', $this->_params['subject']), LOG_NOTICE, __FILE__, __LINE__);
402            return false;
403        } else if (!isset($this->_params['from'])) {
404            $app->logMsg(sprintf('Cannot send email: "%s". FROM not defined.', $this->_params['subject']), LOG_ERR, __FILE__, __LINE__);
405            return false;
406        }
407
408        // Final “to” header can have multiple addresses if in an array.
409        $final_to = is_array($this->_params['to']) ? join(', ', $this->_params['to']) : $this->_params['to'];
410        if (preg_match('/[^,] /', $final_to) && !preg_match('/(?:>$|>,)/', $final_to)) {
411            // If recipient address contains spaces, assume it contains a display name, in which case the email address should be in <br@ckets.com>.
412            $app->logMsg(sprintf('Email addresses should be enclosed in <angle brackets>: %s', $final_to), LOG_NOTICE, __FILE__, __LINE__);
413        }
414
415        // “From” headers are custom headers.
416        $this->appendHeaders(['From' => $this->_params['from']]);
417
418        // Process headers.
419        $final_headers_arr = array();
420        $final_headers = '';
421        foreach ($this->_params['headers'] as $key => $val) {
422            // Validate key and values.
423            if (!isset($val) || !strlen($val)) {
424                $app->logMsg(sprintf('Empty email header provided: %s', $key), LOG_NOTICE, __FILE__, __LINE__);
425                continue;
426            }
427            // Ensure headers meet RFC requirements.
428            // https://datatracker.ietf.org/doc/html/rfc5322#section-2.1.1
429            // https://datatracker.ietf.org/doc/html/rfc5322#section-2.2
430            if (!strlen($key) || strlen($key . $val) > 998 || preg_match('/[^\x21-\x39\x3B-\x7E]/', $key)) {
431                $app->logMsg(sprintf('Broken email header provided: %s=%s', $key, $val), LOG_WARNING, __FILE__, __LINE__);
432                continue;
433            }
434            // If the envelope_sender_address was given as a header, move it to the correct place.
435            if ('envelope_sender_address' == strtolower($key)) {
436                $this->_params['envelope_sender_address'] = isset($this->_params['envelope_sender_address']) ? $this->_params['envelope_sender_address'] : $val;
437                continue;
438            }
439            // If we're sending in sandbox mode, remove any headers with recipient addresses.
440            if ($this->getParam('sandbox_mode') == self::SANDBOX_MODE_REDIRECT && in_array(strtolower($key), array('to', 'cc', 'bcc')) && mb_strpos($val, '@') !== false) {
441                // Don't carry this into the $final_headers.
442                $app->logMsg(sprintf('Skipping header in sandbox mode: %s=%s', $key, $val), LOG_DEBUG, __FILE__, __LINE__);
443                continue;
444            }
445            $final_headers_arr[] = sprintf('%s: %s', $key, $val);
446        }
447        $final_headers = join($this->getParam('crlf'), $final_headers_arr);
448
449        // This is the address where delivery problems are sent to. We must strip off everything except the local@domain part.
450        if (isset($this->_params['envelope_sender_address'])) {
451            $envelope_sender_address = sprintf('<%s>', trim($this->_params['envelope_sender_address'], '<>'));
452        } else {
453            $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']);
454        }
455        if ('' != $envelope_sender_address && $this->validEmail($envelope_sender_address)) {
456            $additional_parameter = sprintf('-f %s', $envelope_sender_address);
457        } else {
458            $additional_parameter = '';
459        }
460
461        if (isset($this->_raw_body)) {
462            $mail_function = 'mail';
463            $final_body = $this->_raw_body;
464        } else {
465            $mail_function = 'mb_send_mail';
466
467            // Wrap email text body, using _template_replaced if replacements have been used, or just a fresh _template if not.
468            $final_body = isset($this->_template_replaced) ? $this->_template_replaced : $this->_template;
469            if (false !== $this->getParam('wrap')) {
470                $final_body = wordwrap($final_body, $this->getParam('line_length'), $this->getParam('crlf'));
471            }
472
473            // Ensure all placeholders have been replaced. Find anything with {...} characters.
474            if (preg_match('/({[^}]+})/', $final_body, $unreplaced_match)) {
475                unset($unreplaced_match[0]);
476                $app->logMsg(sprintf('Unreplaced variable(s) "%s" in template "%s"', getDump($unreplaced_match), $this->_template_filename), LOG_ERR, __FILE__, __LINE__);
477                return false;
478            }
479
480            // Check for mail header injection attacks.
481            $full_mail_content = join($this->getParam('crlf'), array($final_to, $this->_params['subject'], $final_body));
482            if (preg_match("/(^|[\n\r])(Content-Type|MIME-Version|Content-Transfer-Encoding|Bcc|Cc)\s*:/i", $full_mail_content)) {
483                $app->logMsg(sprintf('Mail header injection attack in content: %s', $full_mail_content), LOG_WARNING, __FILE__, __LINE__);
484                return false;
485            }
486        }
487
488        // Enter sandbox mode, if specified.
489        switch ($this->getParam('sandbox_mode')) {
490        case self::SANDBOX_MODE_REDIRECT:
491            if (!$this->getParam('sandbox_to_addr')) {
492                $app->logMsg(sprintf('Email sandbox_mode is SANDBOX_MODE_REDIRECT but sandbox_to_addr is not set.', null), LOG_ERR, __FILE__, __LINE__);
493                break;
494            }
495            $final_to = $this->getParam('sandbox_to_addr');
496            break;
497
498        case self::SANDBOX_MODE_STDERR:
499            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);
500            return true;
501
502        case self::SANDBOX_MODE_LOG:
503            // Temporarily modify log settings to allow full multi-line emails to appear in logs.
504            $log_serialize = $app->getParam('log_serialize');
505            $log_message_max_length = $app->getParam('log_message_max_length');
506            $app->setParam(array('log_serialize' => false, 'log_message_max_length' => 65536));
507            $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__);
508            $app->setParam(array('log_serialize' => $log_serialize, 'log_message_max_length' => $log_message_max_length));
509            return true;
510        }
511
512        // Send email without 5th parameter if safemode is enabled.
513        if (ini_get('safe_mode')) {
514            $ret = $mail_function($final_to, $this->_params['subject'], $final_body, $final_headers);
515        } else {
516            $ret = $mail_function($final_to, $this->_params['subject'], $final_body, $final_headers, $additional_parameter);
517        }
518
519        // Ensure message was successfully accepted for delivery.
520        if ($ret) {
521            $app->logMsg(sprintf('Email successfully sent to %s: %s', $final_to, $this->_params['subject']), LOG_INFO, __FILE__, __LINE__);
522            return true;
523        } else {
524            $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__);
525            return false;
526        }
527    }
528
529    /**
530     * Validates an email address based on the recommendations in RFC 3696.
531     * It's more loose than restrictive, to allow the many valid variants of
532     * email addresses while catching the most common mistakes. Checks an array too.
533     * http://www.faqs.org/rfcs/rfc822.html
534     * http://www.faqs.org/rfcs/rfc2822.html
535     * http://www.faqs.org/rfcs/rfc3696.html
536     * http://www.faqs.org/rfcs/rfc1035.html
537     *
538     * @access  public
539     * @param   mixed  $email  Address to check, string or array.
540     * @return  bool    Validity of address.
541     * @author  Quinn Comendant <quinn@strangecode.com>
542     * @since   30 Nov 2005 22:00:50
543     */
544    public function validEmail($email)
545    {
546        $app =& App::getInstance();
547
548        // If an array, check values recursively.
549        if (is_array($email)) {
550            foreach ($email as $e) {
551                if (!$this->validEmail($e)) {
552                    return false;
553                }
554            }
555            return true;
556        } else {
557            // To be valid email address must match regex and fit within the length constraints.
558            if (preg_match($this->getParam('regex'), $email, $e_parts) && mb_strlen($e_parts[2]) < 64 && mb_strlen($e_parts[3]) < 255) {
559                return true;
560            }
561
562            $app->logMsg(sprintf('Invalid email address: %s', $email), LOG_INFO, __FILE__, __LINE__);
563            return false;
564        }
565    }
566}
567
Note: See TracBrowser for help on using the repository browser.