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

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