root/fEmail.php

Revision 761, 40.1 kB (checked in by wbond, 1 day ago)

Fixed ticket #390 - removed code to double . on windows right after a line break since the bug it was trying to fix does not seem to exist

LineHide Line Numbers
1 <?php
2 /**
3  * Allows creating and sending a single email containing plaintext, HTML, attachments and S/MIME encryption
4  *
5  * Please note that this class uses the [http://php.net/function.mail mail()]
6  * function, and thus would have poor performance if used for mass mailing.
7  *
8  * This class is implemented to use the UTF-8 character encoding. Please see
9  * http://flourishlib.com/docs/UTF-8 for more information.
10  *
11  * @copyright  Copyright (c) 2008-2010 Will Bond, others
12  * @author     Will Bond [wb] <will@flourishlib.com>
13  * @author     Bill Bushee, iMarc LLC [bb-imarc] <bill@imarc.net>
14  * @license    http://flourishlib.com/license
15  *
16  * @package    Flourish
17  * @link       http://flourishlib.com/fEmail
18  *
19  * @version    1.0.0b14
20  * @changes    1.0.0b14  Changed ::send() to not double `.`s at the beginning of lines on Windows since it seemed to break things rather than fix them [wb, 2010-03-05]
21  * @changes    1.0.0b13  Fixed the class to work when safe mode is turned on [wb, 2009-10-23]
22  * @changes    1.0.0b12  Removed duplicate MIME-Version headers that were being included in S/MIME encrypted emails [wb, 2009-10-05]
23  * @changes    1.0.0b11  Updated to use the new fValidationException API [wb, 2009-09-17]
24  * @changes    1.0.0b10  Fixed a bug with sending both an HTML and a plaintext body [bb-imarc, 2009-06-18]
25  * @changes    1.0.0b9   Fixed a bug where the MIME headers were not being set for all emails [wb, 2009-06-12]
26  * @changes    1.0.0b8   Added the method ::clearRecipients() [wb, 2009-05-29]
27  * @changes    1.0.0b7   Email names with UTF-8 characters are now properly encoded [wb, 2009-05-08]
28  * @changes    1.0.0b6   Fixed a bug where <> quoted email addresses in validation messages were not showing [wb, 2009-03-27]
29  * @changes    1.0.0b5   Updated for new fCore API [wb, 2009-02-16]
30  * @changes    1.0.0b4   The recipient error message in ::validate() no longer contains a typo [wb, 2009-02-09]
31  * @changes    1.0.0b3   Fixed a bug with missing content in the fValidationException thrown by ::validate() [wb, 2009-01-14]
32  * @changes    1.0.0b2   Fixed a few bugs with sending S/MIME encrypted/signed emails [wb, 2009-01-10]
33  * @changes    1.0.0b    The initial implementation [wb, 2008-06-23]
34  */
35 class fEmail
36 {
37     // The following constants allow for nice looking callbacks to static methods
38     const fixQmail = 'fEmail::fixQmail';
39     const reset    = 'fEmail::reset';
40    
41     /**
42     * A regular expression to match an email address, exluding those with comments and folding whitespace
43     *
44     * The matches will be:
45    
46     *  - `[0]`: The whole email address
47     *  - `[1]`: The name before the `@`
48     *  - `[2]`: The domain/ip after the `@`
49     *
50     * @var string
51     */
52     const EMAIL_REGEX = '~^[ \t]*(                                                                    # Allow leading whitespace
53                            (?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+")                     # An "atom" or a quoted string
54                            (?:\.[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+"[ \t]*))*  # A . plus another "atom" or a quoted string, any number of times
55                          )@(                                                                          # The @ symbol
56                            (?:[a-z0-9\\-]+\.)+[a-z]{2,}|                                              # Domain name
57                            (?:(?:[01]?\d?\d|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d?\d|2[0-4]\d|25[0-5])    # (or) IP addresses
58                          )[ \t]*$~ixD';                                                               # Allow Trailing whitespace
59    
60     /**
61     * A regular expression to match a `name <email>` string, exluding those with comments and folding whitespace
62     *
63     * The matches will be:
64     *
65     *  - `[0]`: The whole name and email address
66     *  - `[1]`: The name
67     *  - `[2]`: The whole email address
68     *  - `[3]`: The email username before the `@`
69     *  - `[4]`: The email domain/ip after the `@`
70     *
71     * @var string
72     */
73     const NAME_EMAIL_REGEX = '~^[ \t]*(                                                                            # Allow leading whitespace
74                                 (?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*)                 # An "atom" or a quoted string
75                                 (?:\.?[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*))*)  # Another "atom" or a quoted string or a . followed by one of those, any number of times
76                               [ \t]*<[ \t]*((                                                                      # The < encapsulating the email address
77                                 (?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+")                             # An "atom" or a quoted string
78                                 (?:\.[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+"[ \t]*))*          # A . plus another "atom" or a quoted string, any number of times
79                               )@(                                                                                  # The @ symbol
80                                 (?:[a-z0-9\\-]+\.)+[a-z]{2,}|                                                      # Domain nam
81                                 (?:(?:[01]?\d?\d|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d?\d|2[0-4]\d|25[0-5])            # (or) IP addresses
82                               ))[ \t]*>[ \t]*$~ixD';                                                               # Closing > and trailing whitespace
83    
84    
85     /**
86     * Flags if the class should use [http://php.net/popen popen()] to send mail via sendmail
87     *
88     * @var boolean
89     */
90     static private $popen_sendmail = FALSE;
91    
92     /**
93     * Flags if the class should convert `\r\n` to `\n` for qmail. This makes invalid email headers that may work.
94     *
95     * @var boolean
96     */
97     static private $convert_crlf  = FALSE;
98    
99    
100     /**
101     * Composes text using fText if loaded
102     *
103     * @param  string  $message    The message to compose
104     * @param  mixed   $component  A string or number to insert into the message
105     * @param  mixed   ...
106     * @return string  The composed and possible translated message
107     */
108     static protected function compose($message)
109     {
110         $args = array_slice(func_get_args(), 1);
111        
112         if (class_exists('fText', FALSE)) {
113             return call_user_func_array(
114                 array('fText', 'compose'),
115                 array($message, $args)
116             );
117         } else {
118             return vsprintf($message, $args);
119         }
120     }
121    
122    
123     /**
124     * Sets the class to try and fix broken qmail implementations that add `\r` to `\r\n`
125     *
126     * @return void
127     */
128     static public function fixQmail()
129     {
130         if (fCore::checkOS('windows')) {
131             return;
132         }
133        
134         $sendmail_command = ini_get('sendmail_path');
135        
136         if (!$sendmail_command) {
137             self::$convert_crlf = TRUE;
138             trigger_error(
139                 self::compose('The proper fix for sending through qmail is not possible since the sendmail path is not set'),
140                 E_USER_WARNING
141             );
142             trigger_error(
143                 self::compose('Trying to fix qmail by converting all \r\n to \n. This will cause invalid (but possibly functioning) email headers to be generated.'),
144                 E_USER_WARNING
145             );
146         }
147        
148         $sendmail_command_parts = explode(' ', $sendmail_command, 2);
149        
150         $sendmail_path   = $sendmail_command_parts[0];
151         $sendmail_dir    = pathinfo($sendmail_path, PATHINFO_DIRNAME);
152         $sendmail_params = (isset($sendmail_command_parts[1])) ? $sendmail_command_parts[1] : '';
153        
154         // Check to see if we can run sendmail via popen
155         $executable = FALSE;
156         $safe_mode  = FALSE;
157        
158         if (!in_array(strtolower(ini_get('safe_mode')), array('0', '', 'off'))) {
159             $safe_mode = TRUE;
160             $exec_dirs = explode(';', ini_get('safe_mode_exec_dir'));
161             foreach ($exec_dirs as $exec_dir) {
162                 if (stripos($sendmail_dir, $exec_dir) !== 0) {
163                     continue;
164                 }
165                 if (file_exists($sendmail_path) && is_executable($sendmail_path)) {
166                     $executable = TRUE;
167                 }
168             }
169            
170         } else {
171             if (file_exists($sendmail_path) && is_executable($sendmail_path)) {
172                 $executable = TRUE;
173             }
174         }
175        
176         if ($executable) {
177             self::$popen_sendmail = TRUE;
178         } else {
179             self::$convert_crlf   = TRUE;
180             if ($safe_mode) {
181                 trigger_error(
182                     self::compose('The proper fix for sending through qmail is not possible since safe mode is turned on and the sendmail binary is not in one of the paths defined by the safe_mode_exec_dir ini setting'),
183                     E_USER_WARNING
184                 );
185                 trigger_error(
186                     self::compose('Trying to fix qmail by converting all \r\n to \n. This will cause invalid (but possibly functioning) email headers to be generated.'),
187                     E_USER_WARNING
188                 );
189             } else {
190                 trigger_error(
191                     self::compose('The proper fix for sending through qmail is not possible since the sendmail binary could not be found or is not executable'),
192                     E_USER_WARNING
193                 );
194                 trigger_error(
195                     self::compose('Trying to fix qmail by converting all \r\n to \n. This will cause invalid (but possibly functioning) email headers to be generated.'),
196                     E_USER_WARNING
197                 );
198             }
199         }
200     }
201    
202    
203     /**
204     * Resets the configuration of the class
205     *
206     * @internal
207      *
208     * @return void
209     */
210     static public function reset()
211     {
212         self::$popen_sendmail = FALSE;
213         self::$convert_crlf   = FALSE;
214     }
215    
216    
217     /**
218     * Returns `TRUE` for non-empty strings, numbers, objects, empty numbers and string-like numbers (such as `0`, `0.0`, `'0'`)
219     *
220     * @param  mixed $value  The value to check
221     * @return boolean  If the value is string-like
222     */
223     static protected function stringlike($value)
224     {
225         if ((!is_string($value) && !is_object($value) && !is_numeric($value)) || !strlen(trim($value))) {
226             return FALSE;   
227         }
228        
229         return TRUE;
230     }
231    
232    
233     /**
234     * The file contents to attach
235     *
236     * @var array
237     */
238     private $attachments = array();
239    
240     /**
241     * The email address(es) to BCC to
242     *
243     * @var array
244     */
245     private $bcc_emails = array();
246    
247     /**
248     * The email address to bounce to
249     *
250     * @var string
251     */
252     private $bounce_to_email = NULL;
253    
254     /**
255     * The email address(es) to CC to
256     *
257     * @var array
258     */
259     private $cc_emails = array();
260    
261     /**
262     * The email address being sent from
263     *
264     * @var string
265     */
266     private $from_email = NULL;
267    
268     /**
269     * The HTML body of the email
270     *
271     * @var string
272     */
273     private $html_body = NULL;
274    
275     /**
276     * The plaintext body of the email
277     *
278     * @var string
279     */
280     private $plaintext_body = NULL;
281    
282     /**
283     * The recipient's S/MIME PEM certificate filename, used for encryption of the message
284     *
285     * @var string
286     */
287     private $recipients_smime_cert_file = NULL;
288    
289     /**
290     * The email address to reply to
291     *
292     * @var string
293     */
294     private $reply_to_email = NULL;
295    
296     /**
297     * The email address actually sending the email
298     *
299     * @var string
300     */
301     private $sender_email = NULL;
302    
303     /**
304     * The senders's S/MIME PEM certificate filename, used for singing the message
305     *
306     * @var string
307     */
308     private $senders_smime_cert_file = NULL;
309    
310     /**
311     * The senders's S/MIME private key filename, used for singing the message
312     *
313     * @var string
314     */
315     private $senders_smime_pk_file = NULL;
316    
317     /**
318     * The senders's S/MIME private key password, used for singing the message
319     *
320     * @var string
321     */
322     private $senders_smime_pk_password = NULL;
323    
324     /**
325     * If the message should be encrypted using the recipient's S/MIME certificate
326     *
327     * @var boolean
328     */
329     private $smime_encrypt = FALSE;
330    
331     /**
332     * If the message should be signed using the senders's S/MIME private key
333     *
334     * @var boolean
335     */
336     private $smime_sign = FALSE;
337    
338     /**
339     * The subject of the email
340     *
341     * @var string
342     */
343     private $subject = NULL;
344    
345     /**
346     * The email address(es) to send to
347     *
348     * @var array
349     */
350     private $to_emails = array();
351    
352    
353     /**
354     * All requests that hit this method should be requests for callbacks
355     *
356     * @internal
357      *
358     * @param  string $method  The method to create a callback for
359     * @return callback  The callback for the method requested
360     */
361     public function __get($method)
362     {
363         return array($this, $method);       
364     }
365    
366    
367     /**
368     * Adds an attachment to the email
369     *
370     * If a duplicate filename is detected, it will be changed to be unique.
371     *
372     * @param  string $filename   The name of the file to attach
373     * @param  string $mime_type  The mime type of the file
374     * @param  string $contents   The contents of the file
375     * @return void
376     */
377     public function addAttachment($filename, $mime_type, $contents)
378     {
379         if (!self::stringlike($filename)) {
380             throw new fProgrammerException(
381                 'The filename specified, %s, does not appear to be a valid filename',
382                 $filename
383             );
384         }
385        
386         $filename = (string) $filename;
387        
388         $i = 1;
389         while (isset($this->attachments[$filename])) {
390             $filename_info = fFilesystem::getPathInfo($filename);
391             $extension     = ($filename_info['extension']) ? '.' . $filename_info['extension'] : '';
392             $filename      = preg_replace('#_copy\d+$#D', '', $filename_info['filename']) . '_copy' . $i . $extension;
393             $i++;
394         }
395        
396         $this->attachments[$filename] = array(
397             'mime-type' => $mime_type,
398             'contents'  => $contents
399         );
400     }
401    
402    
403     /**
404     * Adds a blind carbon copy (BCC) email recipient
405     *
406     * @param  string $email  The email address to BCC
407     * @param  string $name   The recipient's name
408     * @return void
409     */
410     public function addBCCRecipient($email, $name=NULL)
411     {
412         if (!$email) {
413             return;
414         }
415        
416         $this->bcc_emails[] = $this->combineNameEmail($name, $email);
417     }
418    
419    
420     /**
421     * Adds a carbon copy (CC) email recipient
422     *
423     * @param  string $email  The email address to BCC
424     * @param  string $name   The recipient's name
425     * @return void
426     */
427     public function addCCRecipient($email, $name=NULL)
428     {
429         if (!$email) {
430             return;
431         }
432        
433         $this->cc_emails[] = $this->combineNameEmail($name, $email);
434     }
435    
436    
437     /**
438     * Adds an email recipient
439     *
440     * @param  string $email  The email address to send to
441     * @param  string $name   The recipient's name
442     * @return void
443     */
444     public function addRecipient($email, $name=NULL)
445     {
446         if (!$email) {
447             return;
448         }
449        
450         $this->to_emails[] = $this->combineNameEmail($name, $email);
451     }
452    
453    
454     /**
455     * Takes a multi-address email header and builds it out using an array of emails
456     *
457     * @param  string $header  The header name without `': '`, the header is non-blank, `': '` will be added
458     * @param  array  $emails  The email addresses for the header
459     * @return string  The email header with a trailing `\r\n`
460     */
461     private function buildMultiAddressHeader($header, $emails)
462     {
463         if ($header) {
464             $header .= ': ';
465         }
466        
467         $first = TRUE;
468         $line = 0;
469         foreach ($emails as $email) {
470             if ($first) { $first = FALSE; } else { $header .= ', '; }
471            
472             // Make sure we don't go past the 978 char limit for email headers
473             if (strlen($header . $email) / 950 > $line) {
474                 $header .= "\r\n ";
475                 $line++;
476             }
477            
478             $header .= trim($email);
479         }
480        
481         return $header . "\r\n";
482     }
483    
484    
485     /**
486     * Removes all To, CC and BCC recipients from the email
487     *
488     * @return void
489     */
490     public function clearRecipients()
491     {
492         $this->to_emails  = array();
493         $this->cc_emails  = array();
494         $this->bcc_emails = array();
495     }
496    
497    
498     /**
499     * Creates a 32-character boundary for a multipart message
500     *
501     * @return string  A multipart boundary
502     */
503     private function createBoundary()
504     {
505         $chars      = 'ancdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:-_';
506         $last_index = strlen($chars) - 1;
507         $output     = '';
508        
509         for ($i = 0; $i < 32; $i++) {
510             $output .= $chars[rand(0, $last_index)];
511         }
512         return $output;
513     }
514    
515    
516     /**
517     * Turns a name and email into a `"name" <email>` string, or just `email` if no name is provided
518     *
519     * This method will remove newline characters from the name and email, and
520     * will remove any backslash (`\`) and double quote (`"`) characters from
521     * the name.
522     *
523     * @param  string $name   The name associated with the email address
524     * @param  string $email  The email address
525     * @return string  The '"name" <email>' or 'email' string
526     */
527     private function combineNameEmail($name, $email)
528     {
529         // Strip lower ascii character since they aren't useful in email addresses
530         $email = preg_replace('#[\x0-\x19]+#', '', $email);
531         $name  = preg_replace('#[\x0-\x19]+#', '', $name);
532        
533         if (!$name || fCore::checkOS('windows')) {
534             return $email;
535         }
536        
537         // If the name contains any non-ascii bytes or stuff not allowed
538         // in quoted strings we just make an encoded word out of it
539         if (preg_replace('#[\x80-\xff\x5C\x22]#', '', $name) != $name) {
540             $name = $this->makeEncodedWord($name);
541         } else {
542             $name = '"' . $name . '"';   
543         }
544        
545         return $name . ' <' . $email . '>';
546     }
547    
548    
549     /**
550     * Builds the body of the email
551     *
552     * @param  string $boundary  The boundary to use for the top level mime block
553     * @return string  The message body to be sent to the mail() function
554     */
555     private function createBody($boundary)
556     {
557         $mime_notice = self::compose(
558             "This message has been formatted using MIME. It does not appear that your email client supports MIME."
559         );
560        
561         $body = '';
562        
563         // Build the multi-part/alternative section for the plaintext/HTML combo
564         if ($this->html_body) {
565            
566             // Depending on the other content, we may need to create a new boundary
567             if ($this->attachments) {
568                 $boundary = $this->createBoundary();
569                 $body    .= 'Content-Type: multipart/alternative; boundary="' . $boundary . "\"\r\n\r\n";
570             } else {
571                 $body .= $mime_notice . "\r\n";
572             }
573            
574             $body .= '--' . $boundary . "\r\n";
575             $body .= "Content-Type: text/plain; charset=utf-8\r\n";
576             $body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
577             $body .= $this->makeQuotedPrintable($this->plaintext_body) . "\r\n\r\n";
578             $body .= '--' . $boundary . "\r\n";
579             $body .= "Content-Type: text/html; charset=utf-8\r\n";
580             $body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
581             $body .= $this->makeQuotedPrintable($this->html_body) . "\r\n\r\n";
582             $body .= '--' . $boundary . "--\r\n";
583        
584         // If there is no HTML, just encode the body
585         } else {
586            
587             // Depending on the other content, these headers may be inline or in the real headers
588             if ($this->attachments) {
589                 $body .= "Content-Type: text/plain; charset=utf-8\r\n";
590                 $body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
591             }
592            
593             $body .= $this->makeQuotedPrintable($this->plaintext_body) . "\r\n\r\n";
594         }
595        
596         // If we have attachments, we need to wrap a multipart/mixed around the current body
597         if ($this->attachments) {
598            
599             $multipart_body  = $mime_notice . "\r\n";
600             $multipart_body .= '--' . $boundary . "\r\n";
601             $multipart_body .= $body . "\r\n";
602            
603             foreach ($this->attachments as $filename => $file_info) {
604                 $multipart_body .= '--' . $boundary . "\r\n";
605                 $multipart_body .= 'Content-Type: ' . $file_info['mime-type'] . "\r\n";
606                 $multipart_body .= "Content-Transfer-Encoding: base64\r\n";
607                 $multipart_body .= 'Content-Disposition: attachment; filename="' . $filename . "\";\r\n\r\n";
608                 $multipart_body .= $this->makeBase64($file_info['contents']) . "\r\n\r\n";
609             }
610            
611             $multipart_body .= '--' . $boundary . "--\r\n";
612            
613             $body = $multipart_body;
614         }
615        
616         return $body;
617     }
618    
619    
620     /**
621     * Builds the headers for the email
622     *
623     * @param  string $boundary  The boundary to use for the top level mime block
624     * @return string  The headers to be sent to the [http://php.net/function.mail mail()] function
625     */
626     private function createHeaders($boundary)
627     {
628         $headers = '';
629        
630         if ($this->cc_emails) {
631             $headers .= $this->buildMultiAddressHeader("Cc", $this->cc_emails);
632         }
633        
634         if ($this->bcc_emails) {
635             $headers .= $this->buildMultiAddressHeader("Bcc", $this->bcc_emails);
636         }
637        
638         $headers .= "From: " . trim($this->from_email) . "\r\n";
639        
640         if ($this->reply_to_email) {
641             $headers .= "Reply-To: " . trim($this->reply_to_email) . "\r\n";
642         }
643        
644         if ($this->sender_email) {
645             $headers .= "Sender: " . trim($this->sender_email) . "\r\n";
646         }
647        
648         $headers .= "MIME-Version: 1.0\r\n";
649        
650         if ($this->html_body && !$this->attachments) {
651             $headers .= 'Content-Type: multipart/alternative; boundary="' . $boundary . "\"\r\n";
652         }
653        
654         if (!$this->html_body && !$this->attachments) {
655             $headers .= "Content-Type: text/plain; charset=utf-8\r\n";
656             $headers .= "Content-Transfer-Encoding: quoted-printable\r\n";
657         }
658        
659         if ($this->attachments) {
660             $headers .= 'Content-Type: multipart/mixed; boundary="' . $boundary . "\"\r\n\r\n";
661         }
662        
663         return $headers . "\r\n";
664     }
665    
666    
667     /**
668     * Takes the body of the message and processes it with S/MIME
669     *
670     * @param  string $to       The recipients being sent to
671     * @param  string $subject  The subject of the email
672     * @param  string $headers  The headers for the message
673     * @param  string $body     The message body
674     * @return array  `0` => The message headers, `1` => The message body
675     */
676     private function createSMIMEBody($to, $subject, $headers, $body)
677     {
678         if (!$this->smime_encrypt && !$this->smime_sign) {
679             return array($headers, $body);
680         }
681        
682         $plaintext_file  = tempnam('', '__fEmail_');
683         $ciphertext_file = tempnam('', '__fEmail_');
684        
685         $headers_array = array(
686             'To'      => $to,
687             'Subject' => $subject
688         );
689        
690         preg_match_all('#^([\w\-]+):\s+([^\n]+\n( [^\n]+\n)*)#im', $headers, $header_matches, PREG_SET_ORDER);
691         foreach ($header_matches as $header_match) {
692             $headers_array[$header_match[1]] = trim($header_match[2]);
693         }
694        
695         $body_headers = "";
696         if (isset($headers_array['Content-Type'])) {
697             $body_headers .= 'Content-Type: ' . $headers_array['Content-Type'] . "\r\n";
698         }
699         if (isset($headers_array['Content-Transfer-Encoding'])) {
700             $body_headers .= 'Content-Transfer-Encoding: ' . $headers_array['Content-Transfer-Encoding'] . "\r\n";
701         }
702        
703         if ($body_headers) {
704             $body = $body_headers . "\r\n" . $body;
705         }
706        
707         file_put_contents($plaintext_file, $body);
708         file_put_contents($ciphertext_file, '');
709        
710         // Set up the neccessary S/MIME resources
711         if ($this->smime_sign) {
712             $senders_smime_cert  = file_get_contents($this->senders_smime_cert_file);
713             $senders_private_key = openssl_pkey_get_private(
714                 file_get_contents($this->senders_smime_pk_file),
715                 $this->senders_smime_pk_password
716             );
717            
718             if ($senders_private_key === FALSE) {
719                 throw new fValidationException(
720                     "The sender's S/MIME private key password specified does not appear to be valid for the private key"
721                 );
722             }
723         }
724        
725         if ($this->smime_encrypt) {
726             $recipients_smime_cert = file_get_contents($this->recipients_smime_cert_file);
727         }
728        
729        
730         // If we are going to sign and encrypt, the best way is to sign, encrypt and then sign again
731         if ($this->smime_encrypt && $this->smime_sign) {
732             openssl_pkcs7_sign($plaintext_file, $ciphertext_file, $senders_smime_cert, $senders_private_key, array());
733             openssl_pkcs7_encrypt($ciphertext_file, $plaintext_file, $recipients_smime_cert, array(), NULL, OPENSSL_CIPHER_RC2_128);
734             openssl_pkcs7_sign($plaintext_file, $ciphertext_file, $senders_smime_cert, $senders_private_key, $headers_array);
735        
736         } elseif ($this->smime_sign) {
737             openssl_pkcs7_sign($plaintext_file, $ciphertext_file, $senders_smime_cert, $senders_private_key, $headers_array);
738          
739         } elseif ($this->smime_encrypt) {
740             openssl_pkcs7_encrypt($plaintext_file, $ciphertext_file, $recipients_smime_cert, $headers_array, NULL, OPENSSL_CIPHER_RC2_128);
741         }
742        
743         // It seems that the contents of the ciphertext is not always \r\n line breaks
744         $message = file_get_contents($ciphertext_file);
745         $message = str_replace("\r\n", "\n", $message);
746         $message = str_replace("\r", "\n", $message);
747         $message = str_replace("\n", "\r\n", $message);
748        
749         list($new_headers, $new_body) = explode("\r\n\r\n", $message, 2);
750        
751         $new_headers = preg_replace('#^To:[^\n]+\n( [^\n]+\n)*#mi', '', $new_headers);
752         $new_headers = preg_replace('#^Subject:[^\n]+\n( [^\n]+\n)*#mi', '', $new_headers);
753         $new_headers = preg_replace("#^MIME-Version: 1.0\r?\n#mi", '', $new_headers, 1);
754         $new_headers = preg_replace('#^Content-Type:\s+' . preg_quote($headers_array['Content-Type'], '#') . "\r?\n#mi", '', $new_headers);
755         $new_headers = preg_replace('#^Content-Transfer-Encoding:\s+' . preg_quote($headers_array['Content-Transfer-Encoding'], '#') . "\r?\n#mi", '', $new_headers);
756        
757         unlink($plaintext_file);
758         unlink($ciphertext_file);
759        
760         if ($this->smime_sign) {
761             openssl_pkey_free($senders_private_key);
762         }
763                                  
764         return array($new_headers, $new_body);
765     }
766    
767    
768     /**
769     * Sets the email to be encrypted with S/MIME
770     *
771     * @param  string $recipients_smime_cert_file  The file path to the PEM-encoded S/MIME certificate for the recipient
772     * @return void
773     */
774     public function encrypt($recipients_smime_cert_file)
775     {
776         if (!self::stringlike($recipients_smime_cert_file)) {
777             throw new fProgrammerException(
778                 "The recipient's S/MIME certificate filename specified, %s, does not appear to be a valid filename",
779                 $recipients_smime_cert_file
780             );
781         }
782        
783         $this->smime_encrypt              = TRUE;
784         $this->recipients_smime_cert_file = $recipients_smime_cert_file;
785     }
786    
787    
788     /**
789     * Encodes a string to base64
790     *
791     * @param  string  $content  The content to encode
792     * @return string  The encoded string
793     */
794     private function makeBase64($content)
795     {
796         return chunk_split(base64_encode($content));
797     }
798    
799    
800     /**
801     * Encodes a string to UTF-8 encoded-word
802     *
803     * @param  string  $content  The content to encode
804     * @return string  The encoded string
805     */
806     private function makeEncodedWord($content)
807     {
808         // Homogenize the line-endings to CRLF
809         $content = str_replace("\r\n", "\n", $content);
810         $content = str_replace("\r", "\n", $content);
811         $content = str_replace("\n", "\r\n", $content);
812        
813         // A quick a dirty hex encoding
814         $content = rawurlencode($content);
815         $content = str_replace('=', '%3D', $content);
816         $content = str_replace('%', '=', $content);
817        
818         // Decode characters that don't have to be coded
819         $decodings = array(
820             '=20' => '_', '=21' => '!', '=22' => '"''=23' => '#',
821             '=24' => '$', '=25' => '%', '=26' => '&''=27' => "'",
822             '=28' => '(', '=29' => ')', '=2A' => '*''=2B' => '+',
823             '=2C' => ',', '=2D' => '-', '=2E' => '.''=2F' => '/',
824             '=3A' => ':', '=3B' => ';', '=3C' => '<''=3E' => '>',
825             '=40' => '@', '=5B' => '[', '=5C' => '\\', '=5D' => ']',
826             '=5E' => '^', '=60' => '`', '=7B' => '{''=7C' => '|',
827             '=7D' => '}', '=7E' => '~', ' '   => '_'
828         );
829        
830         $content = strtr($content, $decodings);
831        
832         $length = strlen($content);
833        
834         $prefix = '=?utf-8?Q?';
835         $suffix = '?=';
836        
837         $prefix_length = 10;
838         $suffix_length = 2;
839        
840         // This loop goes through and ensures we are wrapping by 75 chars
841         // including the encoded word delimiters
842         $output = $prefix;
843         $line_length = $prefix_length;
844        
845         for ($i=0; $i<$length; $i++) {
846            
847             // Get info about the next character
848             $char_length = ($content[$i] == '=') ? 3 : 1;
849             $char        = $content[$i];
850             if ($char_length == 3) {
851                 $char .= $content[$i+1] . $content[$i+2];
852             }
853            
854             // If we have too long a line, wrap it
855             if ($line_length + $suffix_length + $char_length > 75) {
856                 $output .= $suffix . "\r\n " . $prefix;
857                 $line_length = $prefix_length + 2;
858             }
859            
860             // Add the character
861             $output .= $char;
862            
863             // Figure out how much longer the line is
864             $line_length += $char_length;
865            
866             // Skip characters if we have an encoded character
867             $i += $char_length-1;
868         }
869        
870         if (substr($output, -2) != $suffix) {
871             $output .= $suffix;
872         }
873        
874         return $output;
875     }
876    
877    
878     /**
879     * Encodes a string to quoted-printable, properly handles UTF-8
880     *
881     * @param  string  $content  The content to encode
882     * @return string  The encoded string
883     */
884     private function makeQuotedPrintable($content)
885     {
886         // Homogenize the line-endings to CRLF
887         $content = str_replace("\r\n", "\n", $content);
888         $content = str_replace("\r", "\n", $content);
889         $content = str_replace("\n", "\r\n", $content);
890        
891         // A quick a dirty hex encoding
892         $content = rawurlencode($content);
893         $content = str_replace('=', '%3D', $content);
894         $content = str_replace('%', '=', $content);
895        
896         // Decode characters that don't have to be coded
897         $decodings = array(
898             '=20' => ' ', '=21' => '!', '=22' => '"', '=23' => '#',
899             '=24' => '$', '=25' => '%', '=26' => '&', '=27' => "'",
900             '=28' => '(', '=29' => ')', '=2A' => '*', '=2B' => '+',
901             '=2C' => ',', '=2D' => '-', '=2E' => '.', '=2F' => '/',
902             '=3A' => ':', '=3B' => ';', '=3C' => '<', '=3E' => '>',
903             '=3F' => '?', '=40' => '@', '=5B' => '[', '=5C' => '\\',
904             '=5D' => ']', '=5E' => '^', '=5F' => '_', '=60' => '`',
905             '=7B' => '{', '=7C' => '|', '=7D' => '}', '=7E' => '~'
906         );
907        
908         $content = strtr($content, $decodings);
909        
910         $output = '';
911        
912         $length = strlen($content);
913        
914         // This loop goes through and ensures we are wrapping by 76 chars
915         $line_length = 0;
916         for ($i=0; $i<$length; $i++) {
917            
918             // Get info about the next character
919             $char_length = ($content[$i] == '=') ? 3 : 1;
920             $char        = $content[$i];
921             if ($char_length == 3) {
922                 $char .= $content[$i+1] . $content[$i+2];
923             }
924            
925             // Skip characters if we have an encoded character, this must be
926             // done before checking for whitespace at the beginning and end of
927             // lines or else characters in the content will be skipped
928             $i += $char_length-1;
929            
930             // Spaces and tabs at the beginning and ending of lines have to be encoded
931             $begining_or_end = $line_length > 69 || $line_length == 0;
932             $tab_or_space    = $char == ' ' || $char == "\t";
933             if ($begining_or_end && $tab_or_space) {
934                 $char_length = 3;
935                 $char        = ($char == ' ') ? '=20' : '=09';
936             }
937            
938             // If we have too long a line, wrap it
939             if ($char != "\r" && $char != "\n" && $line_length + $char_length > 76) {
940                 $output .= "=\r\n";
941                 $line_length = 0;
942             }
943            
944             // Add the character
945             $output .= $char;
946            
947             // Figure out how much longer the line is now
948             if ($char == "\r" || $char == "\n") {
949                 $line_length = 0;
950             } else {
951                 $line_length += $char_length;
952             }
953         }
954        
955         return $output;
956     }
957    
958    
959     /**
960     * Sends the email
961     *
962     * @throws fValidationException  When ::validate() throws an exception
963     *
964     * @return void
965     */
966     public function send()
967     {
968         $this->validate();
969        
970         $to = trim($this->buildMultiAddressHeader("", $this->to_emails));
971        
972         $top_level_boundary = $this->createBoundary();
973         $headers            = $this->createHeaders($top_level_boundary);
974        
975         $subject = str_replace(array("\r", "\n"), '', $this->subject);
976         $subject = $this->makeEncodedWord($subject);
977        
978         $body = $this->createBody($top_level_boundary);
979        
980         if ($this->smime_encrypt || $this->smime_sign) {
981             list($headers, $body) = $this->createSMIMEBody($to, $subject, $headers, $body);
982         }
983        
984         // Sendmail when not in safe mode will allow you to set the envelope from address via the -f parameter
985         $parameters = NULL;
986         if (!fCore::checkOS('windows') && $this->bounce_to_email) {
987             preg_match(self::EMAIL_REGEX, $this->bounce_to_email, $matches);
988             $parameters = '-f ' . $matches[0];
989        
990         // Windows takes the Return-Path email from the sendmail_from ini setting
991         } elseif (fCore::checkOS('windows') && $this->bounce_to_email) {
992             $old_sendmail_from = ini_get('sendmail_from');
993             preg_match(self::EMAIL_REGEX, $this->bounce_to_email, $matches);
994             ini_set('sendmail_from', $matches[0]);
995         }
996        
997         // Remove extra line breaks
998         $headers = trim($headers);
999         $body    = trim($body);
1000        
1001         // This is a gross qmail fix that is a last resort
1002         if (self::$popen_sendmail || self::$convert_crlf) {
1003             $to      = str_replace("\r\n", "\n", $to);
1004             $subject = str_replace("\r\n", "\n", $subject);
1005             $body    = str_replace("\r\n", "\n", $body);
1006             $headers = str_replace("\r\n", "\n", $headers);
1007         }
1008        
1009         // If the user is using qmail and wants to try to fix the \r\r\n line break issue
1010         if (self::$popen_sendmail) {
1011             $sendmail_command = ini_get('sendmail_path');
1012             if ($parameters) {
1013                 $sendmail_command .= ' ' . $parameters;
1014             }
1015            
1016             $sendmail_process = popen($sendmail_command, 'w');
1017             fprintf($sendmail_process, "To: %s\n", $to);
1018             fprintf($sendmail_process, "Subject: %s\n", $subject);
1019             if ($headers) {
1020                 fprintf($sendmail_process, "%s\n", $headers);
1021             }
1022             fprintf($sendmail_process, "\n%s\n", $body);
1023             $error = pclose($sendmail_process);
1024            
1025         // This is the normal way to send mail
1026         } else {
1027             if ($parameters) {
1028                 $error = !mail($to, $subject, $body, $headers, $parameters);
1029             } else {
1030                 $error = !mail($to, $subject, $body, $headers);
1031             }
1032         }
1033        
1034         if (fCore::checkOS('windows') && $this->bounce_to_email) {
1035             ini_set('sendmail_from', $old_sendmail_from);
1036         }
1037        
1038         if ($error) {
1039             throw new fConnectivityException(
1040                 'An error occured while trying to send the email entitled %s',
1041                 $this->subject
1042             );
1043         }
1044     }
1045    
1046    
1047     /**
1048     * Adds the email address the email will be bounced to
1049     *
1050     * This email address will be set to the `Return-Path` header.
1051     *
1052     * @param  string $email  The email address to bounce to
1053     * @return void
1054     */
1055     public function setBounceToEmail($email)
1056     {
1057         if (ini_get('safe_mode') && !fCore::checkOS('windows')) {
1058             throw new fProgrammerException('It is not possible to set a Bounce-To Email address when safe mode is enabled on a non-Windows server');
1059         }
1060         if (!$email) {
1061             return;
1062         }
1063        
1064         $this->bounce_to_email = $this->combineNameEmail('', $email);
1065     }
1066    
1067    
1068     /**
1069     * Adds the `From:` email address to the email
1070     *
1071     * @param  string $email  The email address being sent from
1072     * @param  string $name   The from email user's name - unfortunately on windows this is ignored
1073     * @return void
1074     */
1075     public function setFromEmail($email, $name=NULL)
1076     {
1077         if (!$email) {
1078             return;
1079         }
1080        
1081         $this->from_email = $this->combineNameEmail($name, $email);
1082     }
1083    
1084    
1085     /**
1086     * Sets the HTML version of the email body
1087     *
1088     * This method accepts either ASCII or UTF-8 encoded text. Please see
1089     * http://flourishlib.com/docs/UTF-8 for more information.
1090     *
1091     * @param  string $html  The HTML version of the email body
1092     * @return void
1093     */
1094     public function setHTMLBody($html)
1095     {
1096         $this->html_body = $html;
1097     }
1098    
1099    
1100     /**
1101     * Sets the plaintext version of the email body
1102     *
1103     * This method accepts either ASCII or UTF-8 encoded text. Please see
1104     * http://flourishlib.com/docs/UTF-8 for more information.
1105     *
1106     * @param  string $plaintext  The plaintext version of the email body
1107     * @return void
1108     */
1109     public function setBody($plaintext)
1110     {
1111         $this->plaintext_body = $plaintext;
1112     }
1113    
1114    
1115     /**
1116     * Adds the `Reply-To:` email address to the email
1117     *
1118     * @param  string $email  The email address to reply to
1119     * @param  string $name   The reply-to email user's name
1120     * @return void
1121     */
1122     public function setReplyToEmail($email, $name=NULL)
1123     {
1124         if (!$email) {
1125             return;
1126         }
1127        
1128         $this->reply_to_email = $this->combineNameEmail($name, $email);
1129     }
1130    
1131    
1132     /**
1133     * Adds the `Sender:` email address to the email
1134     *
1135     * The `Sender:` header is used to indicate someone other than the `From:`
1136     * address is actually submitting the message to the network.
1137     *
1138     * @param  string $email  The email address the message is actually being sent from
1139     * @param  string $name   The sender email user's name
1140     * @return void
1141     */
1142     public function setSenderEmail($email, $name=NULL)
1143     {
1144         if (!$email) {
1145             return;
1146         }
1147        
1148         $this->sender_email = $this->combineNameEmail($name, $email);
1149     }
1150    
1151    
1152     /**
1153     * Sets the subject of the email
1154     *
1155     * This method accepts either ASCII or UTF-8 encoded text. Please see
1156     * http://flourishlib.com/docs/UTF-8 for more information.
1157     *
1158     * @param  string $subject  The subject of the email
1159     * @return void
1160     */
1161     public function setSubject($subject)
1162     {
1163         $this->subject = $subject;
1164     }
1165    
1166    
1167     /**
1168     * Sets the email to be signed with S/MIME
1169     *
1170     * @param  string $senders_smime_cert_file    The file path to the sender's PEM-encoded S/MIME certificate
1171     * @param  string $senders_smime_pk_file      The file path to the sender's S/MIME private key
1172     * @param  string $senders_smime_pk_password  The password for the sender's S/MIME private key
1173     * @return void
1174     */
1175     public function sign($senders_smime_cert_file, $senders_smime_pk_file, $senders_smime_pk_password)
1176     {
1177         if (!self::stringlike($senders_smime_cert_file)) {
1178             throw new fProgrammerException(
1179                 "The sender's S/MIME certificate file specified, %s, does not appear to be a valid filename",
1180                 $senders_smime_cert_file
1181             );
1182         }
1183         if (!file_exists($senders_smime_cert_file) || !is_readable($senders_smime_cert_file)) {
1184             throw new fEnvironmentException(
1185                 "The sender's S/MIME certificate file specified, %s, does not exist or could not be read",
1186                 $senders_smime_cert_file
1187             );
1188         }
1189        
1190         if (!self::stringlike($senders_smime_pk_file)) {
1191             throw new fProgrammerException(
1192                 "The sender's S/MIME primary key file specified, %s, does not appear to be a valid filename",
1193                 $senders_smime_pk_file
1194             );
1195         }
1196         if (!file_exists($senders_smime_pk_file) || !is_readable($senders_smime_pk_file)) {
1197             throw new fEnvironmentException(
1198                 "The sender's S/MIME primary key file specified, %s, does not exist or could not be read",
1199                 $senders_smime_pk_file
1200             );
1201         }
1202        
1203         $this->smime_sign                = TRUE;
1204         $this->senders_smime_cert_file   = $senders_smime_cert_file;
1205         $this->senders_smime_pk_file     = $senders_smime_pk_file;
1206         $this->senders_smime_pk_password = $senders_smime_pk_password;
1207     }
1208    
1209    
1210     /**
1211     * Validates that all of the parts of the email are valid
1212     *
1213     * @throws fValidationException  When part of the email is missing or formatted incorrectly
1214     *
1215     * @return void
1216     */
1217     private function validate()
1218     {
1219         $validation_messages = array();
1220        
1221         // Check all multi-address email field
1222         $multi_address_field_list = array(
1223             'to_emails'  => self::compose('recipient'),
1224             'cc_emails'  => self::compose('CC recipient'),
1225             'bcc_emails' => self::compose('BCC recipient')
1226         );
1227        
1228         foreach ($multi_address_field_list as $field => $name) {
1229             foreach ($this->$field as $email) {
1230                 if ($email && !preg_match(self::NAME_EMAIL_REGEX, $email) && !preg_match(self::EMAIL_REGEX, $email)) {
1231                     $validation_messages[] = htmlspecialchars(self::compose(
1232                         'The %1$s %2$s is not a valid email address. Should be like "John Smith" <name@example.com> or name@example.com.',
1233                         $name,
1234                         $email
1235                     ), ENT_QUOTES, 'UTF-8');
1236                 }
1237             }
1238         }
1239        
1240         // Check all single-address email fields
1241         $single_address_field_list = array(
1242             'from_email'      => self::compose('From email address'),
1243             'reply_to_email'  => self::compose('Reply-To email address'),
1244             'sender_email'    => self::compose('Sender email address'),
1245             'bounce_to_email' => self::compose('Bounce-To email address')
1246         );
1247        
1248         foreach ($single_address_field_list as $field => $name) {
1249             if ($this->$field && !preg_match(self::NAME_EMAIL_REGEX, $this->$field) && !preg_match(self::EMAIL_REGEX, $this->$field)) {
1250                 $validation_messages[] = htmlspecialchars(self::compose(
1251                     'The %1$s %2$s is not a valid email address. Should be like "John Smith" <name@example.com> or name@example.com.',
1252                     $name,
1253                     $this->$field
1254                 ), ENT_QUOTES, 'UTF-8');
1255             }
1256         }
1257        
1258         // Make sure the required fields are all set
1259         if (!$this->to_emails) {
1260             $validation_messages[] = self::compose(
1261                 "Please provide at least one recipient"
1262             );
1263         }
1264        
1265         if (!$this->from_email) {
1266             $validation_messages[] = self::compose(
1267                 "Please provide the from email address"
1268             );
1269         }
1270        
1271         if (!self::stringlike($this->subject)) {
1272             $validation_messages[] = self::compose(
1273                 "Please provide an email subject"
1274             );
1275         }
1276        
1277         if (strpos($this->subject, "\n") !== FALSE) {
1278             $validation_messages[] = self::compose(
1279                 "The subject contains one or more newline characters"
1280             );   
1281         }
1282        
1283         if (!self::stringlike($this->plaintext_body)) {
1284             $validation_messages[] = self::compose(
1285                 "Please provide a plaintext email body"
1286             );
1287         }
1288        
1289         // Make sure the attachments look good
1290         foreach ($this->attachments as $filename => $file_info) {
1291             if (!self::stringlike($file_info['mime-type'])) {
1292                 $validation_messages[] = self::compose(
1293                     "No mime-type was specified for the attachment %s",
1294                     $filename
1295                 );
1296             }
1297             if (!self::stringlike($file_info['contents'])) {
1298                 $validation_messages[] = self::compose(
1299                     "The attachment %s appears to be a blank file",
1300                     $filename
1301                 );
1302             }
1303         }
1304        
1305         if ($validation_messages) {
1306             throw new fValidationException(
1307                 'The email could not be sent because:',
1308                 $validation_messages
1309             );   
1310         }
1311     }
1312 }
1313  
1314  
1315  
1316 /**
1317  * Copyright (c) 2008-2010 Will Bond <will@flourishlib.com>, others
1318  *
1319  * Permission is hereby granted, free of charge, to any person obtaining a copy
1320  * of this software and associated documentation files (the "Software"), to deal
1321  * in the Software without restriction, including without limitation the rights
1322  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1323  * copies of the Software, and to permit persons to whom the Software is
1324  * furnished to do so, subject to the following conditions:
1325  *
1326  * The above copyright notice and this permission notice shall be included in
1327  * all copies or substantial portions of the Software.
1328  *
1329  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1330  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1331  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1332  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1333  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1334  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
1335  * THE SOFTWARE.
1336  */