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

Last change on this file since 767 was 767, checked in by anonymous, 2 years ago

Add App param ‘template_ext’ used to inform services where to find header and footer templates. Minor fixes.

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