PluginProbe
Postie / trunk
Postie vtrunk
1.9.80 1.9.79 1.9.78 1.9.77 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.10 1.7.11 1.7.12 1.7.13 1.7.14 1.7.15 1.7.16 1.7.17 1.7.18 1.7.2 1.7.20 All 239 releases
postie / lib / fEmail.php

fEmail.php in Postie trunk, at lib/fEmail.php

1,655 lines 64.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Allows creating and sending a single email containing plaintext, HTML, attachments and S/MIME encryption
5 *
6 * Please note that this class uses the [http://php.net/function.mail mail()]
7 * function by default. Developers that are sending multiple emails, or need
8 * SMTP support, should use fSMTP with this class.
9 *
10 * This class is implemented to use the UTF-8 character encoding. Please see
11 * http://flourishlib.com/docs/UTF-8 for more information.
12 *
13 * @copyright Copyright (c) 2008-2011 Will Bond, others
14 * @author Will Bond [wb] <will@flourishlib.com>
15 * @author Bill Bushee, iMarc LLC [bb-imarc] <bill@imarc.net>
16 * @author netcarver [n] <fContrib@netcarving.com>
17 * @license http://flourishlib.com/license
18 *
19 * @package Flourish
20 * @link http://flourishlib.com/fEmail
21 *
22 * @version 1.0.0b30
23 * @changes 1.0.0b30 Changed methods to return instance for method chaining [n, 2011-09-12]
24 * @changes 1.0.0b29 Changed ::combineNameEmail() to be a static method and to be exposed publicly for use by other classes [wb, 2011-07-26]
25 * @changes 1.0.0b28 Fixed ::addAttachment() and ::addRelatedFile() to properly handle duplicate filenames [wb, 2011-05-17]
26 * @changes 1.0.0b27 Fixed a bug with generating FQDNs on some Windows machines [wb, 2011-02-24]
27 * @changes 1.0.0b26 Added ::addCustomerHeader() [wb, 2011-02-02]
28 * @changes 1.0.0b25 Fixed a bug with finding the FQDN on non-Windows machines [wb, 2011-01-19]
29 * @changes 1.0.0b24 Backwards Compatibility Break - the `$contents` parameter of ::addAttachment() is now first instead of third, ::addAttachment() will now accept fFile objects for the `$contents` parameter, added ::addRelatedFile() [wb, 2010-12-01]
30 * @changes 1.0.0b23 Fixed a bug on Windows where emails starting with a `.` would have the `.` removed [wb, 2010-09-11]
31 * @changes 1.0.0b22 Revamped the FQDN code and added ::getFQDN() [wb, 2010-09-07]
32 * @changes 1.0.0b21 Added a check to prevent permissions warnings when getting the FQDN on Windows machines [wb, 2010-09-02]
33 * @changes 1.0.0b20 Fixed ::send() to only remove the name of a recipient when dealing with the `mail()` function on Windows and to leave it when using fSMTP [wb, 2010-06-22]
34 * @changes 1.0.0b19 Changed ::send() to return the message id for the email, fixed the email regexes to require [] around IPs [wb, 2010-05-05]
35 * @changes 1.0.0b18 Fixed the name of the static method ::unindentExpand() [wb, 2010-04-28]
36 * @changes 1.0.0b17 Added the static method ::unindentExpand() [wb, 2010-04-26]
37 * @changes 1.0.0b16 Added support for sending emails via fSMTP [wb, 2010-04-20]
38 * @changes 1.0.0b15 Added the `$unindent_expand_constants` parameter to ::setBody(), added ::loadBody() and ::loadHTMLBody(), fixed HTML emails with attachments [wb, 2010-03-14]
39 * @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]
40 * @changes 1.0.0b13 Fixed the class to work when safe mode is turned on [wb, 2009-10-23]
41 * @changes 1.0.0b12 Removed duplicate MIME-Version headers that were being included in S/MIME encrypted emails [wb, 2009-10-05]
42 * @changes 1.0.0b11 Updated to use the new fValidationException API [wb, 2009-09-17]
43 * @changes 1.0.0b10 Fixed a bug with sending both an HTML and a plaintext body [bb-imarc, 2009-06-18]
44 * @changes 1.0.0b9 Fixed a bug where the MIME headers were not being set for all emails [wb, 2009-06-12]
45 * @changes 1.0.0b8 Added the method ::clearRecipients() [wb, 2009-05-29]
46 * @changes 1.0.0b7 Email names with UTF-8 characters are now properly encoded [wb, 2009-05-08]
47 * @changes 1.0.0b6 Fixed a bug where <> quoted email addresses in validation messages were not showing [wb, 2009-03-27]
48 * @changes 1.0.0b5 Updated for new fCore API [wb, 2009-02-16]
49 * @changes 1.0.0b4 The recipient error message in ::validate() no longer contains a typo [wb, 2009-02-09]
50 * @changes 1.0.0b3 Fixed a bug with missing content in the fValidationException thrown by ::validate() [wb, 2009-01-14]
51 * @changes 1.0.0b2 Fixed a few bugs with sending S/MIME encrypted/signed emails [wb, 2009-01-10]
52 * @changes 1.0.0b The initial implementation [wb, 2008-06-23]
53 */
54 class fEmail {
55
56 // The following constants allow for nice looking callbacks to static methods
57 const combineNameEmail = 'fEmail::combineNameEmail';
58 const fixQmail = 'fEmail::fixQmail';
59 const getFQDN = 'fEmail::getFQDN';
60 const reset = 'fEmail::reset';
61 const unindentExpand = 'fEmail::unindentExpand';
62
63 /**
64 * A regular expression to match an email address, exluding those with comments and folding whitespace
65 *
66 * The matches will be:
67 *
68 * - `[0]`: The whole email address
69 * - `[1]`: The name before the `@`
70 * - `[2]`: The domain/ip after the `@`
71 *
72 * @var string
73 */
74 const EMAIL_REGEX = '~^[ \t]*( # Allow leading whitespace
75 (?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+") # An "atom" or a quoted string
76 (?:\.[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+"[ \t]*))* # A . plus another "atom" or a quoted string, any number of times
77 )@( # The @ symbol
78 (?:[a-z0-9\\-]+\.)+[a-z]{2,}| # Domain name
79 \[(?:(?:[01]?\d?\d|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d?\d|2[0-4]\d|25[0-5])\] # (or) IP addresses
80 )[ \t]*$~ixD'; # Allow Trailing whitespace
81
82 /**
83 * A regular expression to match a `name <email>` string, exluding those with comments and folding whitespace
84 *
85 * The matches will be:
86 *
87 * - `[0]`: The whole name and email address
88 * - `[1]`: The name
89 * - `[2]`: The whole email address
90 * - `[3]`: The email username before the `@`
91 * - `[4]`: The email domain/ip after the `@`
92 *
93 * @var string
94 */
95 const NAME_EMAIL_REGEX = '~^[ \t]*( # Allow leading whitespace
96 (?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*) # An "atom" or a quoted string
97 (?:\.?[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+[ \t]*|"[^"\\\\\n\r]+"[ \t]*))*) # Another "atom" or a quoted string or a . followed by one of those, any number of times
98 [ \t]*<[ \t]*(( # The < encapsulating the email address
99 (?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+") # An "atom" or a quoted string
100 (?:\.[ \t]*(?:[^\x00-\x20\(\)<>@,;:\\\\"\.\[\]]+|"[^"\\\\\n\r]+"[ \t]*))* # A . plus another "atom" or a quoted string, any number of times
101 )@( # The @ symbol
102 (?:[a-z0-9\\-]+\.)+[a-z]{2,}| # Domain nam
103 \[(?:(?:[01]?\d?\d|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d?\d|2[0-4]\d|25[0-5])\] # (or) IP addresses
104 ))[ \t]*>[ \t]*$~ixD'; # Closing > and trailing whitespace
105
106 /**
107 * Flags if the class should convert `\r\n` to `\n` for qmail. This makes invalid email headers that may work.
108 *
109 * @var boolean
110 */
111
112 static private $convert_crlf = FALSE;
113
114 /**
115 * The local fully-qualified domain name
116 */
117 static private $fqdn;
118
119 /**
120 * Flags if the class should use [http://php.net/popen popen()] to send mail via sendmail
121 *
122 * @var boolean
123 */
124 static private $popen_sendmail = FALSE;
125
126 /**
127 * Turns a name and email into a `"name" <email>` string, or just `email` if no name is provided
128 *
129 * This method will remove newline characters from the name and email, and
130 * will remove any backslash (`\`) and double quote (`"`) characters from
131 * the name.
132 *
133 * @internal
134 *
135 * @param string $name The name associated with the email address
136 * @param string $email The email address
137 * @return string The '"name" <email>' or 'email' string
138 */
139 static public function combineNameEmail($name, $email) {
140 // Strip lower ascii character since they aren't useful in email addresses
141 $email = preg_replace('#[\x0-\x19]+#', '', $email);
142 $name = preg_replace('#[\x0-\x19]+#', '', $name);
143
144 if (!$name) {
145 return $email;
146 }
147
148 // If the name contains any non-ascii bytes or stuff not allowed
149 // in quoted strings we just make an encoded word out of it
150 if (preg_replace('#[\x80-\xff\x5C\x22]#', '', $name) != $name) {
151 // The longest header name that will contain email addresses is
152 // "Bcc: ", which is 5 characters long
153 $name = self::makeEncodedWord($name, 5);
154 } else {
155 $name = '"' . $name . '"';
156 }
157
158 return $name . ' <' . $email . '>';
159 }
160
161 /**
162 * Composes text using fText if loaded
163 *
164 * @param string $message The message to compose
165 * @param mixed $component A string or number to insert into the message
166 * @param mixed ...
167 * @return string The composed and possible translated message
168 */
169 static protected function compose($message) {
170 $args = array_slice(func_get_args(), 1);
171
172 if (class_exists('fText', FALSE)) {
173 return call_user_func_array(
174 array('fText', 'compose'), array($message, $args)
175 );
176 } else {
177 return vsprintf($message, $args);
178 }
179 }
180
181 /**
182 * Sets the class to try and fix broken qmail implementations that add `\r` to `\r\n`
183 *
184 * Before trying to fix qmail with this method, please try using fSMTP
185 * to connect to `localhost` and pass the fSMTP object to ::send().
186 *
187 * @return void
188 */
189 static public function fixQmail() {
190 if (fCore::checkOS('windows')) {
191 return;
192 }
193
194 $sendmail_command = ini_get('sendmail_path');
195
196 if (!$sendmail_command) {
197 self::$convert_crlf = TRUE;
198 trigger_error(
199 self::compose('The proper fix for sending through qmail is not possible since the sendmail path is not set'), E_USER_WARNING
200 );
201 trigger_error(
202 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.'), E_USER_WARNING
203 );
204 }
205
206 $sendmail_command_parts = explode(' ', $sendmail_command, 2);
207
208 $sendmail_path = $sendmail_command_parts[0];
209 $sendmail_dir = pathinfo($sendmail_path, PATHINFO_DIRNAME);
210 $sendmail_params = (isset($sendmail_command_parts[1])) ? $sendmail_command_parts[1] : '';
211
212 // Check to see if we can run sendmail via popen
213 $executable = FALSE;
214
215 if (file_exists($sendmail_path) && is_executable($sendmail_path)) {
216 $executable = TRUE;
217 }
218
219 if ($executable) {
220 self::$popen_sendmail = TRUE;
221 } else {
222 self::$convert_crlf = TRUE;
223 trigger_error(
224 self::compose('The proper fix for sending through qmail is not possible since the sendmail binary could not be found or is not executable'), E_USER_WARNING
225 );
226 trigger_error(
227 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.'), E_USER_WARNING
228 );
229 }
230 }
231
232 /**
233 * Returns the fully-qualified domain name of the server
234 *
235 * @internal
236 *
237 * @return string The fully-qualified domain name of the server
238 */
239 static public function getFQDN() {
240 if (self::$fqdn !== NULL) {
241 return self::$fqdn;
242 }
243
244 if (isset($_ENV['HOST'])) {
245 self::$fqdn = $_ENV['HOST'];
246 }
247 if (strpos(self::$fqdn, '.') === FALSE && isset($_ENV['HOSTNAME'])) {
248 self::$fqdn = $_ENV['HOSTNAME'];
249 }
250 if (strpos(self::$fqdn, '.') === FALSE) {
251 //self::$fqdn = php_uname('n');
252 }
253
254 if (strpos(self::$fqdn, '.') === FALSE) {
255
256 $can_exec = !in_array('exec', array_map('trim', explode(',', ini_get('disable_functions'))));
257 if (fCore::checkOS('linux') && $can_exec) {
258 self::$fqdn = trim(shell_exec('hostname --fqdn'));
259 } elseif (fCore::checkOS('windows')) {
260 $shell = new COM('WScript.Shell');
261 $tcpip_key = 'HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip';
262 try {
263 $domain = $shell->RegRead($tcpip_key . '\Parameters\NV Domain');
264 } catch (com_exception $e) {
265 try {
266 $domain = $shell->RegRead($tcpip_key . '\Parameters\DhcpDomain');
267 } catch (com_exception $e) {
268 try {
269 $adapters = $shell->RegRead($tcpip_key . '\Linkage\Route');
270 foreach ($adapters as $adapter) {
271 if ($adapter[0] != '{') {
272 continue;
273 }
274 try {
275 $domain = $shell->RegRead($tcpip_key . '\Interfaces\\' . $adapter . '\Domain');
276 } catch (com_exception $e) {
277 try {
278 $domain = $shell->RegRead($tcpip_key . '\Interfaces\\' . $adapter . '\DhcpDomain');
279 } catch (com_exception $e) {
280
281 }
282 }
283 }
284 } catch (com_exception $e) {
285
286 }
287 }
288 }
289 if (!empty($domain)) {
290 self::$fqdn .= '.' . $domain;
291 }
292 } elseif (!fCore::checkOS('windows') && !ini_get('open_basedir') && file_exists('/etc/resolv.conf')) {
293 $output = file_get_contents('/etc/resolv.conf');
294 if (preg_match('#^domain ([a-z0-9_.-]+)#im', $output, $match)) {
295 self::$fqdn .= '.' . $match[1];
296 }
297 }
298 }
299
300 return self::$fqdn;
301 }
302
303 /**
304 * Encodes a string to UTF-8 encoded-word
305 *
306 * @param string $content The content to encode
307 * @param integer $first_line_prefix_length The length of any prefix applied to the first line of the encoded word - this allows properly accounting for a header name
308 * @return string The encoded string
309 */
310 static private function makeEncodedWord($content, $first_line_prefix_length) {
311 // Homogenize the line-endings to CRLF
312 $content = str_replace("\r\n", "\n", $content);
313 $content = str_replace("\r", "\n", $content);
314 $content = str_replace("\n", "\r\n", $content);
315
316 // Encoded word is not required if all characters are ascii
317 if (!preg_match('#[\x80-\xFF]#', $content)) {
318 return $content;
319 }
320
321 // A quick a dirty hex encoding
322 $content = rawurlencode($content);
323 $content = str_replace('=', '%3D', $content);
324 $content = str_replace('%', '=', $content);
325
326 // Decode characters that don't have to be coded
327 $decodings = array(
328 '=20' => '_', '=21' => '!', '=22' => '"', '=23' => '#',
329 '=24' => '$', '=25' => '%', '=26' => '&', '=27' => "'",
330 '=28' => '(', '=29' => ')', '=2A' => '*', '=2B' => '+',
331 '=2C' => ',', '=2D' => '-', '=2E' => '.', '=2F' => '/',
332 '=3A' => ':', '=3B' => ';', '=3C' => '<', '=3E' => '>',
333 '=40' => '@', '=5B' => '[', '=5C' => '\\', '=5D' => ']',
334 '=5E' => '^', '=60' => '`', '=7B' => '{', '=7C' => '|',
335 '=7D' => '}', '=7E' => '~', ' ' => '_'
336 );
337
338 $content = strtr($content, $decodings);
339
340 $length = strlen($content);
341
342 $prefix = '=?utf-8?Q?';
343 $suffix = '?=';
344
345 $prefix_length = 10;
346 $suffix_length = 2;
347
348 // This loop goes through and ensures we are wrapping by 75 chars
349 // including the encoded word delimiters
350 $output = $prefix;
351 $line_length = $prefix_length + $first_line_prefix_length;
352
353 for ($i = 0; $i < $length; $i++) {
354
355 // Get info about the next character
356 $char_length = ($content[$i] == '=') ? 3 : 1;
357 $char = $content[$i];
358 if ($char_length == 3) {
359 $char .= $content[$i + 1] . $content[$i + 2];
360 }
361
362 // If we have too long a line, wrap it
363 if ($line_length + $suffix_length + $char_length > 75) {
364 $output .= $suffix . "\r\n " . $prefix;
365 $line_length = $prefix_length + 2;
366 }
367
368 // Add the character
369 $output .= $char;
370
371 // Figure out how much longer the line is
372 $line_length += $char_length;
373
374 // Skip characters if we have an encoded character
375 $i += $char_length - 1;
376 }
377
378 if (substr($output, -2) != $suffix) {
379 $output .= $suffix;
380 }
381
382 return $output;
383 }
384
385 /**
386 * Resets the configuration of the class
387 *
388 * @internal
389 *
390 * @return void
391 */
392 static public function reset() {
393 self::$convert_crlf = FALSE;
394 self::$fqdn = NULL;
395 self::$popen_sendmail = FALSE;
396 }
397
398 /**
399 * Returns `TRUE` for non-empty strings, numbers, objects, empty numbers and string-like numbers (such as `0`, `0.0`, `'0'`)
400 *
401 * @param mixed $value The value to check
402 * @return boolean If the value is string-like
403 */
404 static protected function stringlike($value) {
405 if ((!is_string($value) && !is_object($value) && !is_numeric($value)) || !strlen(trim($value))) {
406 return FALSE;
407 }
408
409 return TRUE;
410 }
411
412 /**
413 * Takes a block of text, unindents it and replaces {CONSTANT} tokens with the constant's value
414 *
415 * @param string $text The text to unindent and replace constants in
416 * @return string The unindented text
417 */
418 static public function unindentExpand($text) {
419 $text = preg_replace('#^[ \t]*\n|\n[ \t]*$#D', '', $text);
420
421 if (preg_match('#^[ \t]+(?=\S)#m', $text, $match)) {
422 $text = preg_replace('#^' . preg_quote($match[0]) . '#m', '', $text);
423 }
424
425 preg_match_all('#\{([a-z][a-z0-9_]*)\}#i', $text, $constants, PREG_SET_ORDER);
426 foreach ($constants as $constant) {
427 if (!defined($constant[1])) {
428 continue;
429 }
430 $text = preg_replace('#' . preg_quote($constant[0], '#') . '#', constant($constant[1]), $text, 1);
431 }
432
433 return $text;
434 }
435
436 /**
437 * The file contents to attach
438 *
439 * @var array
440 */
441 private $attachments = array();
442
443 /**
444 * The email address(es) to BCC to
445 *
446 * @var array
447 */
448 private $bcc_emails = array();
449
450 /**
451 * The email address to bounce to
452 *
453 * @var string
454 */
455 private $bounce_to_email = NULL;
456
457 /**
458 * The email address(es) to CC to
459 *
460 * @var array
461 */
462 private $cc_emails = array();
463
464 /**
465 * Custom headers
466 *
467 * @var array
468 */
469 private $custom_headers = array();
470
471 /**
472 * The email address being sent from
473 *
474 * @var string
475 */
476 private $from_email = NULL;
477
478 /**
479 * The HTML body of the email
480 *
481 * @var string
482 */
483 private $html_body = NULL;
484
485 /**
486 * The Message-ID header for the email
487 *
488 * @var string
489 */
490 private $message_id = NULL;
491
492 /**
493 * The plaintext body of the email
494 *
495 * @var string
496 */
497 private $plaintext_body = NULL;
498
499 /**
500 * The recipient's S/MIME PEM certificate filename, used for encryption of the message
501 *
502 * @var string
503 */
504 private $recipients_smime_cert_file = NULL;
505
506 /**
507 * The files to include as multipart/related
508 *
509 * @var array
510 */
511 private $related_files = array();
512
513 /**
514 * The email address to reply to
515 *
516 * @var string
517 */
518 private $reply_to_email = NULL;
519
520 /**
521 * The email address actually sending the email
522 *
523 * @var string
524 */
525 private $sender_email = NULL;
526
527 /**
528 * The senders's S/MIME PEM certificate filename, used for singing the message
529 *
530 * @var string
531 */
532 private $senders_smime_cert_file = NULL;
533
534 /**
535 * The senders's S/MIME private key filename, used for singing the message
536 *
537 * @var string
538 */
539 private $senders_smime_pk_file = NULL;
540
541 /**
542 * The senders's S/MIME private key password, used for singing the message
543 *
544 * @var string
545 */
546 private $senders_smime_pk_password = NULL;
547
548 /**
549 * If the message should be encrypted using the recipient's S/MIME certificate
550 *
551 * @var boolean
552 */
553 private $smime_encrypt = FALSE;
554
555 /**
556 * If the message should be signed using the senders's S/MIME private key
557 *
558 * @var boolean
559 */
560 private $smime_sign = FALSE;
561
562 /**
563 * The subject of the email
564 *
565 * @var string
566 */
567 private $subject = NULL;
568
569 /**
570 * The email address(es) to send to
571 *
572 * @var array
573 */
574 private $to_emails = array();
575
576 /**
577 * Initializes fEmail for creating message ids
578 *
579 * @return fEmail
580 */
581 public function __construct() {
582 $this->message_id = '<' . fCryptography::randomString(10, 'hexadecimal') . '.' . time() . '@' . self::getFQDN() . '>';
583 }
584
585 /**
586 * All requests that hit this method should be requests for callbacks
587 *
588 * @internal
589 *
590 * @param string $method The method to create a callback for
591 * @return callback The callback for the method requested
592 */
593 public function __get($method) {
594 return array($this, $method);
595 }
596
597 /**
598 * Adds an attachment to the email
599 *
600 * If a duplicate filename is detected, it will be changed to be unique.
601 *
602 * @param string|fFile $contents The contents of the file
603 * @param string $filename The name to give the attachement - optional if `$contents` is an fFile object
604 * @param string $mime_type The mime type of the file - this allows overriding the mime type of the file if incorrectly detected
605 * @return fEmail The email object, to allow for method chaining
606 */
607 public function addAttachment($contents, $filename = NULL, $mime_type = NULL) {
608 $this->extrapolateFileInfo($contents, $filename, $mime_type);
609
610 while (isset($this->attachments[$filename])) {
611 $filename = $this->generateNewFilename($filename);
612 }
613
614 $this->attachments[$filename] = array(
615 'mime-type' => $mime_type,
616 'contents' => $contents
617 );
618
619 return $this;
620 }
621
622 /**
623 * Adds a “related” file to the email, returning the `Content-ID` for use in HTML
624 *
625 * The purpose of a related file is to be able to reference it in part of
626 * the HTML body. Image `src` URLs can reference a related file by starting
627 * the URL with `cid:` and then inserting the `Content-ID`.
628 *
629 * If a duplicate filename is detected, it will be changed to be unique.
630 *
631 * @param string|fFile $contents The contents of the file
632 * @param string $filename The name to give the attachement - optional if `$contents` is an fFile object
633 * @param string $mime_type The mime type of the file - this allows overriding the mime type of the file if incorrectly detected
634 * @return string The fully-formed `cid:` URL for use in HTML `src` attributes
635 */
636 public function addRelatedFile($contents, $filename = NULL, $mime_type = NULL) {
637 $this->extrapolateFileInfo($contents, $filename, $mime_type);
638
639 while (isset($this->related_files[$filename])) {
640 $filename = $this->generateNewFilename($filename);
641 }
642
643 $cid = count($this->related_files) . '.' . substr($this->message_id, 1, -1);
644
645 $this->related_files[$filename] = array(
646 'mime-type' => $mime_type,
647 'contents' => $contents,
648 'content-id' => '<' . $cid . '>'
649 );
650
651 return 'cid:' . $cid;
652 }
653
654 /**
655 * Adds a blind carbon copy (BCC) email recipient
656 *
657 * @param string $email The email address to BCC
658 * @param string $name The recipient's name
659 * @return fEmail The email object, to allow for method chaining
660 */
661 public function addBCCRecipient($email, $name = NULL) {
662 if (!$email) {
663 return;
664 }
665
666 $this->bcc_emails[] = self::combineNameEmail($name, $email);
667
668 return $this;
669 }
670
671 /**
672 * Adds a carbon copy (CC) email recipient
673 *
674 * @param string $email The email address to BCC
675 * @param string $name The recipient's name
676 * @return fEmail The email object, to allow for method chaining
677 */
678 public function addCCRecipient($email, $name = NULL) {
679 if (!$email) {
680 return;
681 }
682
683 $this->cc_emails[] = self::combineNameEmail($name, $email);
684
685 return $this;
686 }
687
688 /**
689 * Allows adding a custom header to the email
690 *
691 * If the method is called multiple times with the same name, the last
692 * value will be used.
693 *
694 * Please note that this class will properly format the header, including
695 * adding the `:` between the name and value and wrapping values that are
696 * too long for a single line.
697 *
698 * @param string $name The name of the header
699 * @param string $value The value of the header
700 * @param array :$headers An associative array of `{name} => {value}`
701 * @return fEmail The email object, to allow for method chaining
702 */
703 public function addCustomHeader($name, $value = NULL) {
704 if ($value === NULL && is_array($name)) {
705 foreach ($name as $key => $value) {
706 $this->addCustomHeader($key, $value);
707 }
708 return;
709 }
710
711 $lower_name = fUTF8::lower($name);
712 $this->custom_headers[$lower_name] = array($name, $value);
713
714 return $this;
715 }
716
717 /**
718 * Adds an email recipient
719 *
720 * @param string $email The email address to send to
721 * @param string $name The recipient's name
722 * @return fEmail The email object, to allow for method chaining
723 */
724 public function addRecipient($email, $name = NULL) {
725 if (!$email) {
726 return;
727 }
728
729 $this->to_emails[] = self::combineNameEmail($name, $email);
730
731 return $this;
732 }
733
734 /**
735 * Takes a multi-address email header and builds it out using an array of emails
736 *
737 * @param string $header The header name without `': '`, the header is non-blank, `': '` will be added
738 * @param array $emails The email addresses for the header
739 * @return string The email header with a trailing `\r\n`
740 */
741 private function buildMultiAddressHeader($header, $emails) {
742 $header .= ': ';
743
744 $first = TRUE;
745 $line = 1;
746 foreach ($emails as $email) {
747 if ($first) {
748 $first = FALSE;
749 } else {
750 $header .= ', ';
751 }
752
753 // Try to stay within the recommended 78 character line limit
754 $last_crlf_pos = (integer) strrpos($header, "\r\n");
755 if (strlen($header . $email) - $last_crlf_pos > 78) {
756 $header .= "\r\n ";
757 $line++;
758 }
759
760 $header .= trim($email);
761 }
762
763 return $header . "\r\n";
764 }
765
766 /**
767 * Removes all To, CC and BCC recipients from the email
768 *
769 * @return fEmail The email object, to allow for method chaining
770 */
771 public function clearRecipients() {
772 $this->to_emails = array();
773 $this->cc_emails = array();
774 $this->bcc_emails = array();
775
776 return $this;
777 }
778
779 /**
780 * Creates a 32-character boundary for a multipart message
781 *
782 * @return string A multipart boundary
783 */
784 private function createBoundary() {
785 $chars = 'ancdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:-_';
786 $last_index = strlen($chars) - 1;
787 $output = '';
788
789 for ($i = 0; $i < 28; $i++) {
790 $output .= $chars[rand(0, $last_index)];
791 }
792 return $output;
793 }
794
795 /**
796 * Builds the body of the email
797 *
798 * @param string $boundary The boundary to use for the top level mime block
799 * @return string The message body to be sent to the mail() function
800 */
801 private function createBody($boundary) {
802 $boundary_stack = array($boundary);
803
804 $mime_notice = self::compose(
805 "This message has been formatted using MIME. It does not appear that your\r\nemail client supports MIME."
806 );
807
808 $body = '';
809
810 if ($this->html_body || $this->attachments) {
811 $body .= $mime_notice . "\r\n\r\n";
812 }
813
814 if ($this->html_body && $this->related_files && $this->attachments) {
815 $body .= '--' . end($boundary_stack) . "\r\n";
816 $boundary_stack[] = $this->createBoundary();
817 $body .= 'Content-Type: multipart/related; boundary="' . end($boundary_stack) . "\"\r\n\r\n";
818 }
819
820 if ($this->html_body && ($this->attachments || $this->related_files)) {
821 $body .= '--' . end($boundary_stack) . "\r\n";
822 $boundary_stack[] = $this->createBoundary();
823 $body .= 'Content-Type: multipart/alternative; boundary="' . end($boundary_stack) . "\"\r\n\r\n";
824 }
825
826 if ($this->html_body || $this->attachments) {
827 $body .= '--' . end($boundary_stack) . "\r\n";
828 $body .= "Content-Type: text/plain; charset=utf-8\r\n";
829 $body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
830 }
831
832 $body .= $this->makeQuotedPrintable($this->plaintext_body) . "\r\n";
833
834 if ($this->html_body) {
835 $body .= '--' . end($boundary_stack) . "\r\n";
836 $body .= "Content-Type: text/html; charset=utf-8\r\n";
837 $body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
838 $body .= $this->makeQuotedPrintable($this->html_body) . "\r\n";
839 }
840
841 if ($this->related_files) {
842 $body .= '--' . end($boundary_stack) . "--\r\n";
843 array_pop($boundary_stack);
844
845 foreach ($this->related_files as $filename => $file_info) {
846 $body .= '--' . end($boundary_stack) . "\r\n";
847 $body .= 'Content-Type: ' . $file_info['mime-type'] . '; name="' . $filename . "\"\r\n";
848 $body .= "Content-Transfer-Encoding: base64\r\n";
849 $body .= 'Content-ID: ' . $file_info['content-id'] . "\r\n\r\n";
850 $body .= $this->makeBase64($file_info['contents']) . "\r\n";
851 }
852 }
853
854 if ($this->attachments) {
855
856 if ($this->html_body) {
857 $body .= '--' . end($boundary_stack) . "--\r\n";
858 array_pop($boundary_stack);
859 }
860
861 foreach ($this->attachments as $filename => $file_info) {
862 $body .= '--' . end($boundary_stack) . "\r\n";
863 $body .= 'Content-Type: ' . $file_info['mime-type'] . "\r\n";
864 $body .= "Content-Transfer-Encoding: base64\r\n";
865 $body .= 'Content-Disposition: attachment; filename="' . $filename . "\";\r\n\r\n";
866 $body .= $this->makeBase64($file_info['contents']) . "\r\n";
867 }
868 }
869
870 if ($this->html_body || $this->attachments) {
871 $body .= '--' . end($boundary_stack) . "--\r\n";
872 array_pop($boundary_stack);
873 }
874
875 return $body;
876 }
877
878 /**
879 * Builds the headers for the email
880 *
881 * @param string $boundary The boundary to use for the top level mime block
882 * @param string $message_id The message id for the message
883 * @return string The headers to be sent to the [http://php.net/function.mail mail()] function
884 */
885 private function createHeaders($boundary, $message_id) {
886 $headers = '';
887
888 if ($this->cc_emails) {
889 $headers .= $this->buildMultiAddressHeader("Cc", $this->cc_emails);
890 }
891
892 if ($this->bcc_emails) {
893 $headers .= $this->buildMultiAddressHeader("Bcc", $this->bcc_emails);
894 }
895
896 $headers .= "From: " . trim($this->from_email) . "\r\n";
897
898 if ($this->reply_to_email) {
899 $headers .= "Reply-To: " . trim($this->reply_to_email) . "\r\n";
900 }
901
902 if ($this->sender_email) {
903 $headers .= "Sender: " . trim($this->sender_email) . "\r\n";
904 }
905
906 foreach ($this->custom_headers as $header_info) {
907 $header_prefix = $header_info[0] . ': ';
908 $headers .= $header_prefix . self::makeEncodedWord($header_info[1], strlen($header_prefix)) . "\r\n";
909 }
910
911 $headers .= "Message-ID: " . $message_id . "\r\n";
912 $headers .= "MIME-Version: 1.0\r\n";
913
914 if (!$this->html_body && !$this->attachments) {
915 $headers .= "Content-Type: text/plain; charset=utf-8\r\n";
916 $headers .= "Content-Transfer-Encoding: quoted-printable\r\n";
917 } elseif ($this->html_body && !$this->attachments) {
918 if ($this->related_files) {
919 $headers .= 'Content-Type: multipart/related; boundary="' . $boundary . "\"\r\n";
920 } else {
921 $headers .= 'Content-Type: multipart/alternative; boundary="' . $boundary . "\"\r\n";
922 }
923 } elseif ($this->attachments) {
924 $headers .= 'Content-Type: multipart/mixed; boundary="' . $boundary . "\"\r\n";
925 }
926
927 return $headers . "\r\n";
928 }
929
930 /**
931 * Takes the body of the message and processes it with S/MIME
932 *
933 * @param string $to The recipients being sent to
934 * @param string $subject The subject of the email
935 * @param string $headers The headers for the message
936 * @param string $body The message body
937 * @return array `0` => The message headers, `1` => The message body
938 */
939 private function createSMIMEBody($to, $subject, $headers, $body) {
940 if (!$this->smime_encrypt && !$this->smime_sign) {
941 return array($headers, $body);
942 }
943
944 $plaintext_file = tempnam('', '__fEmail_');
945 $ciphertext_file = tempnam('', '__fEmail_');
946
947 $headers_array = array(
948 'To' => $to,
949 'Subject' => $subject
950 );
951
952 preg_match_all('#^([\w\-]+):\s+([^\n]+\n( [^\n]+\n)*)#im', $headers, $header_matches, PREG_SET_ORDER);
953 foreach ($header_matches as $header_match) {
954 $headers_array[$header_match[1]] = trim($header_match[2]);
955 }
956
957 $body_headers = "";
958 if (isset($headers_array['Content-Type'])) {
959 $body_headers .= 'Content-Type: ' . $headers_array['Content-Type'] . "\r\n";
960 }
961 if (isset($headers_array['Content-Transfer-Encoding'])) {
962 $body_headers .= 'Content-Transfer-Encoding: ' . $headers_array['Content-Transfer-Encoding'] . "\r\n";
963 }
964
965 if ($body_headers) {
966 $body = $body_headers . "\r\n" . $body;
967 }
968
969 file_put_contents($plaintext_file, $body);
970 file_put_contents($ciphertext_file, '');
971
972 // Set up the neccessary S/MIME resources
973 if ($this->smime_sign) {
974 $senders_smime_cert = file_get_contents($this->senders_smime_cert_file);
975 $senders_private_key = openssl_pkey_get_private(
976 file_get_contents($this->senders_smime_pk_file), $this->senders_smime_pk_password
977 );
978
979 if ($senders_private_key === FALSE) {
980 throw new fValidationException("The sender's S/MIME private key password specified does not appear to be valid for the private key");
981 }
982 }
983
984 if ($this->smime_encrypt) {
985 $recipients_smime_cert = file_get_contents($this->recipients_smime_cert_file);
986 }
987
988
989 // If we are going to sign and encrypt, the best way is to sign, encrypt and then sign again
990 if ($this->smime_encrypt && $this->smime_sign) {
991 openssl_pkcs7_sign($plaintext_file, $ciphertext_file, $senders_smime_cert, $senders_private_key, array());
992 openssl_pkcs7_encrypt($ciphertext_file, $plaintext_file, $recipients_smime_cert, array(), NULL, OPENSSL_CIPHER_RC2_128);
993 openssl_pkcs7_sign($plaintext_file, $ciphertext_file, $senders_smime_cert, $senders_private_key, $headers_array);
994 } elseif ($this->smime_sign) {
995 openssl_pkcs7_sign($plaintext_file, $ciphertext_file, $senders_smime_cert, $senders_private_key, $headers_array);
996 } elseif ($this->smime_encrypt) {
997 openssl_pkcs7_encrypt($plaintext_file, $ciphertext_file, $recipients_smime_cert, $headers_array, NULL, OPENSSL_CIPHER_RC2_128);
998 }
999
1000 // It seems that the contents of the ciphertext is not always \r\n line breaks
1001 $message = file_get_contents($ciphertext_file);
1002 $message = str_replace("\r\n", "\n", $message);
1003 $message = str_replace("\r", "\n", $message);
1004 $message = str_replace("\n", "\r\n", $message);
1005
1006 list($new_headers, $new_body) = explode("\r\n\r\n", $message, 2);
1007
1008 $new_headers = preg_replace('#^To:[^\n]+\n( [^\n]+\n)*#mi', '', $new_headers);
1009 $new_headers = preg_replace('#^Subject:[^\n]+\n( [^\n]+\n)*#mi', '', $new_headers);
1010 $new_headers = preg_replace("#^MIME-Version: 1.0\r?\n#mi", '', $new_headers, 1);
1011 $new_headers = preg_replace('#^Content-Type:\s+' . preg_quote($headers_array['Content-Type'], '#') . "\r?\n#mi", '', $new_headers);
1012 $new_headers = preg_replace('#^Content-Transfer-Encoding:\s+' . preg_quote($headers_array['Content-Transfer-Encoding'], '#') . "\r?\n#mi", '', $new_headers);
1013
1014 unlink($plaintext_file);
1015 unlink($ciphertext_file);
1016
1017 if ($this->smime_sign) {
1018 openssl_pkey_free($senders_private_key);
1019 }
1020
1021 return array($new_headers, $new_body);
1022 }
1023
1024 /**
1025 * Sets the email to be encrypted with S/MIME
1026 *
1027 * @param string $recipients_smime_cert_file The file path to the PEM-encoded S/MIME certificate for the recipient
1028 * @return fEmail The email object, to allow for method chaining
1029 */
1030 public function encrypt($recipients_smime_cert_file) {
1031 if (!extension_loaded('openssl')) {
1032 throw new fEnvironmentException('S/MIME encryption was requested for an email, but the %s extension is not installed', 'openssl');
1033 }
1034
1035 if (!self::stringlike($recipients_smime_cert_file)) {
1036 throw new fProgrammerException("The recipient's S/MIME certificate filename specified, %s, does not appear to be a valid filename", $recipients_smime_cert_file);
1037 }
1038
1039 $this->smime_encrypt = TRUE;
1040 $this->recipients_smime_cert_file = $recipients_smime_cert_file;
1041
1042 return $this;
1043 }
1044
1045 /**
1046 * Extracts just the email addresses from an array of strings containing an
1047 * <email@address.com> or "Name" <email@address.com> combination.
1048 *
1049 * @param array $list The list of email or name/email to extract from
1050 * @return array The email addresses
1051 */
1052 private function extractEmails($list) {
1053 $output = array();
1054 foreach ($list as $email) {
1055 if (preg_match(self::NAME_EMAIL_REGEX, $email, $match)) {
1056 $output[] = $match[2];
1057 } else {
1058 preg_match(self::EMAIL_REGEX, $email, $match);
1059 $output[] = $match[0];
1060 }
1061 }
1062 return $output;
1063 }
1064
1065 /**
1066 * Extracts the filename and mime-type from an fFile object
1067 *
1068 * @param string|fFile &$contents The file to extrapolate the info from
1069 * @param string &$filename The filename to use for the file
1070 * @param string &$mime_type The mime type of the file
1071 * @return void
1072 */
1073 private function extrapolateFileInfo(&$contents, &$filename, &$mime_type) {
1074 if ($contents instanceof fFile) {
1075 if ($filename === NULL) {
1076 $filename = $contents->getName();
1077 }
1078 if ($mime_type === NULL) {
1079 $mime_type = $contents->getMimeType();
1080 }
1081 $contents = $contents->read();
1082 } else {
1083 if (!self::stringlike($filename)) {
1084 throw new fProgrammerException('The filename specified, %s, does not appear to be a valid filename', $filename);
1085 }
1086
1087 $filename = (string) $filename;
1088
1089 if ($mime_type === NULL) {
1090 $mime_type = fFile::determineMimeType($filename, $contents);
1091 }
1092 }
1093 }
1094
1095 /**
1096 * Generates a new filename in an attempt to create a unique name
1097 *
1098 * @param string $filename The filename to generate another name for
1099 * @return string The newly generated filename
1100 */
1101 private function generateNewFilename($filename) {
1102 $filename_info = fFilesystem::getPathInfo($filename);
1103 if (preg_match('#_copy(\d+)($|\.)#D', $filename_info['filename'], $match)) {
1104 $i = $match[1] + 1;
1105 } else {
1106 $i = 1;
1107 }
1108 $extension = ($filename_info['extension']) ? '.' . $filename_info['extension'] : '';
1109 return preg_replace('#_copy\d+$#D', '', $filename_info['filename']) . '_copy' . $i . $extension;
1110 }
1111
1112 /**
1113 * Loads the plaintext version of the email body from a file and applies replacements
1114 *
1115 * The should contain either ASCII or UTF-8 encoded text. Please see
1116 * http://flourishlib.com/docs/UTF-8 for more information.
1117 *
1118 * @throws fValidationException When no file was specified, the file does not exist or the path specified is not a file
1119 *
1120 * @param string|fFile $file The plaintext version of the email body
1121 * @param array $replacements The method will search the contents of the file for each key and replace it with the corresponding value
1122 * @return fEmail The email object, to allow for method chaining
1123 */
1124 public function loadBody($file, $replacements = array()) {
1125 if (!$file instanceof fFile) {
1126 $file = new fFile($file);
1127 }
1128
1129 $plaintext = $file->read();
1130 if ($replacements) {
1131 $plaintext = strtr($plaintext, $replacements);
1132 }
1133
1134 $this->plaintext_body = $plaintext;
1135
1136 return $this;
1137 }
1138
1139 /**
1140 * Loads the plaintext version of the email body from a file and applies replacements
1141 *
1142 * The should contain either ASCII or UTF-8 encoded text. Please see
1143 * http://flourishlib.com/docs/UTF-8 for more information.
1144 *
1145 * @throws fValidationException When no file was specified, the file does not exist or the path specified is not a file
1146 *
1147 * @param string|fFile $file The plaintext version of the email body
1148 * @param array $replacements The method will search the contents of the file for each key and replace it with the corresponding value
1149 * @return fEmail The email object, to allow for method chaining
1150 */
1151 public function loadHTMLBody($file, $replacements = array()) {
1152 if (!$file instanceof fFile) {
1153 $file = new fFile($file);
1154 }
1155
1156 $html = $file->read();
1157 if ($replacements) {
1158 $html = strtr($html, $replacements);
1159 }
1160
1161 $this->html_body = $html;
1162
1163 return $this;
1164 }
1165
1166 /**
1167 * Encodes a string to base64
1168 *
1169 * @param string $content The content to encode
1170 * @return string The encoded string
1171 */
1172 private function makeBase64($content) {
1173 return chunk_split(base64_encode($content));
1174 }
1175
1176 /**
1177 * Encodes a string to quoted-printable, properly handles UTF-8
1178 *
1179 * @param string $content The content to encode
1180 * @return string The encoded string
1181 */
1182 private function makeQuotedPrintable($content) {
1183 // Homogenize the line-endings to CRLF
1184 $content = str_replace("\r\n", "\n", $content);
1185 $content = str_replace("\r", "\n", $content);
1186 $content = str_replace("\n", "\r\n", $content);
1187
1188 // A quick a dirty hex encoding
1189 $content = rawurlencode($content);
1190 $content = str_replace('=', '%3D', $content);
1191 $content = str_replace('%', '=', $content);
1192
1193 // Decode characters that don't have to be coded
1194 $decodings = array(
1195 '=20' => ' ', '=21' => '!', '=22' => '"', '=23' => '#',
1196 '=24' => '$', '=25' => '%', '=26' => '&', '=27' => "'",
1197 '=28' => '(', '=29' => ')', '=2A' => '*', '=2B' => '+',
1198 '=2C' => ',', '=2D' => '-', '=2E' => '.', '=2F' => '/',
1199 '=3A' => ':', '=3B' => ';', '=3C' => '<', '=3E' => '>',
1200 '=3F' => '?', '=40' => '@', '=5B' => '[', '=5C' => '\\',
1201 '=5D' => ']', '=5E' => '^', '=5F' => '_', '=60' => '`',
1202 '=7B' => '{', '=7C' => '|', '=7D' => '}', '=7E' => '~'
1203 );
1204
1205 $content = strtr($content, $decodings);
1206
1207 $output = '';
1208
1209 $length = strlen($content);
1210
1211 // This loop goes through and ensures we are wrapping by 76 chars
1212 $line_length = 0;
1213 for ($i = 0; $i < $length; $i++) {
1214
1215 // Get info about the next character
1216 $char_length = ($content[$i] == '=') ? 3 : 1;
1217 $char = $content[$i];
1218 if ($char_length == 3) {
1219 $char .= $content[$i + 1] . $content[$i + 2];
1220 }
1221
1222 // Skip characters if we have an encoded character, this must be
1223 // done before checking for whitespace at the beginning and end of
1224 // lines or else characters in the content will be skipped
1225 $i += $char_length - 1;
1226
1227 // Spaces and tabs at the beginning and ending of lines have to be encoded
1228 $begining_or_end = $line_length > 69 || $line_length == 0;
1229 $tab_or_space = $char == ' ' || $char == "\t";
1230 if ($begining_or_end && $tab_or_space) {
1231 $char_length = 3;
1232 $char = ($char == ' ') ? '=20' : '=09';
1233 }
1234
1235 // If we have too long a line, wrap it
1236 if ($char != "\r" && $char != "\n" && $line_length + $char_length > 75) {
1237 $output .= "=\r\n";
1238 $line_length = 0;
1239 }
1240
1241 // Add the character
1242 $output .= $char;
1243
1244 // Figure out how much longer the line is now
1245 if ($char == "\r" || $char == "\n") {
1246 $line_length = 0;
1247 } else {
1248 $line_length += $char_length;
1249 }
1250 }
1251
1252 return $output;
1253 }
1254
1255 /**
1256 * Sends the email
1257 *
1258 * The return value is the message id, which should be included as the
1259 * `Message-ID` header of the email. While almost all SMTP servers will not
1260 * modify this value, testing has indicated at least one (smtp.live.com
1261 * for Windows Live Mail) does.
1262 *
1263 * @throws fValidationException When ::validate() throws an exception
1264 *
1265 * @param fSMTP $connection The SMTP connection to send the message over
1266 * @return string The message id for the message - see method description for details
1267 */
1268 public function send($connection = NULL) {
1269 $this->validate();
1270
1271 // The mail() function on Windows doesn't support names in headers so
1272 // we must strip them down to just the email address
1273 if ($connection === NULL && fCore::checkOS('windows')) {
1274 $vars = array('bcc_emails', 'bounce_to_email', 'cc_emails', 'from_email', 'reply_to_email', 'sender_email', 'to_emails');
1275 foreach ($vars as $var) {
1276 if (!is_array($this->$var)) {
1277 if (preg_match(self::NAME_EMAIL_REGEX, $this->$var, $match)) {
1278 $this->$var = $match[2];
1279 }
1280 } else {
1281 $new_emails = array();
1282 foreach ($this->$var as $email) {
1283 if (preg_match(self::NAME_EMAIL_REGEX, $email, $match)) {
1284 $email = $match[2];
1285 }
1286 $new_emails[] = $email;
1287 }
1288 $this->$var = $new_emails;
1289 }
1290 }
1291 }
1292
1293 $to = substr(trim($this->buildMultiAddressHeader("To", $this->to_emails)), 4);
1294
1295 $top_level_boundary = $this->createBoundary();
1296 $headers = $this->createHeaders($top_level_boundary, $this->message_id);
1297
1298 $subject = str_replace(array("\r", "\n"), '', $this->subject);
1299 $subject = self::makeEncodedWord($subject, 9);
1300
1301 $body = $this->createBody($top_level_boundary);
1302
1303 if ($this->smime_encrypt || $this->smime_sign) {
1304 list($headers, $body) = $this->createSMIMEBody($to, $subject, $headers, $body);
1305 }
1306
1307 // Remove extra line breaks
1308 $headers = trim($headers);
1309 $body = trim($body);
1310
1311 if ($connection) {
1312 $to_emails = $this->extractEmails($this->to_emails);
1313 $to_emails = array_merge($to_emails, $this->extractEmails($this->cc_emails));
1314 $to_emails = array_merge($to_emails, $this->extractEmails($this->bcc_emails));
1315 $from = $this->bounce_to_email ? $this->bounce_to_email : current($this->extractEmails(array($this->from_email)));
1316 $connection->send($from, $to_emails, "To: " . $to . "\r\nSubject: " . $subject . "\r\n" . $headers, $body);
1317 return $this->message_id;
1318 }
1319
1320 // Sendmail when not in safe mode will allow you to set the envelope from address via the -f parameter
1321 $parameters = NULL;
1322 if (!fCore::checkOS('windows') && $this->bounce_to_email) {
1323 preg_match(self::EMAIL_REGEX, $this->bounce_to_email, $matches);
1324 $parameters = '-f ' . $matches[0];
1325
1326 // Windows takes the Return-Path email from the sendmail_from ini setting
1327 } elseif (fCore::checkOS('windows') && $this->bounce_to_email) {
1328 $old_sendmail_from = ini_get('sendmail_from');
1329 preg_match(self::EMAIL_REGEX, $this->bounce_to_email, $matches);
1330 ini_set('sendmail_from', $matches[0]);
1331 }
1332
1333 // This is a gross qmail fix that is a last resort
1334 if (self::$popen_sendmail || self::$convert_crlf) {
1335 $to = str_replace("\r\n", "\n", $to);
1336 $subject = str_replace("\r\n", "\n", $subject);
1337 $body = str_replace("\r\n", "\n", $body);
1338 $headers = str_replace("\r\n", "\n", $headers);
1339 }
1340
1341 // If the user is using qmail and wants to try to fix the \r\r\n line break issue
1342 if (self::$popen_sendmail) {
1343 $sendmail_command = ini_get('sendmail_path');
1344 if ($parameters) {
1345 $sendmail_command .= ' ' . $parameters;
1346 }
1347
1348 $sendmail_process = popen($sendmail_command, 'w');
1349 fprintf($sendmail_process, "To: %s\n", $to);
1350 fprintf($sendmail_process, "Subject: %s\n", $subject);
1351 if ($headers) {
1352 fprintf($sendmail_process, "%s\n", $headers);
1353 }
1354 fprintf($sendmail_process, "\n%s\n", $body);
1355 $error = pclose($sendmail_process);
1356
1357 // This is the normal way to send mail
1358 } else {
1359 // On Windows, mail() sends directly to an SMTP server and will
1360 // strip a leading . from the body
1361 if (fCore::checkOS('windows')) {
1362 $body = preg_replace('#^\.#', '..', $body);
1363 }
1364
1365 if ($parameters) {
1366 $error = !mail($to, $subject, $body, $headers, $parameters);
1367 } else {
1368 $error = !mail($to, $subject, $body, $headers);
1369 }
1370 }
1371
1372 if (fCore::checkOS('windows') && $this->bounce_to_email) {
1373 ini_set('sendmail_from', $old_sendmail_from);
1374 }
1375
1376 if ($error) {
1377 throw new fConnectivityException('An error occured while trying to send the email entitled %s', $this->subject);
1378 }
1379
1380 return $this->message_id;
1381 }
1382
1383 /**
1384 * Sets the plaintext version of the email body
1385 *
1386 * This method accepts either ASCII or UTF-8 encoded text. Please see
1387 * http://flourishlib.com/docs/UTF-8 for more information.
1388 *
1389 * @param string $plaintext The plaintext version of the email body
1390 * @param boolean $unindent_expand_constants If this is `TRUE`, the body will be unindented as much as possible and {CONSTANT_NAME} will be replaced with the value of the constant
1391 * @return fEmail The email object, to allow for method chaining
1392 */
1393 public function setBody($plaintext, $unindent_expand_constants = FALSE) {
1394 if ($unindent_expand_constants) {
1395 $plaintext = self::unindentExpand($plaintext);
1396 }
1397
1398 $this->plaintext_body = $plaintext;
1399
1400 return $this;
1401 }
1402
1403 /**
1404 * Adds the email address the email will be bounced to
1405 *
1406 * This email address will be set to the `Return-Path` header.
1407 *
1408 * @param string $email The email address to bounce to
1409 * @return fEmail The email object, to allow for method chaining
1410 */
1411 public function setBounceToEmail($email) {
1412 if (!$email) {
1413 return;
1414 }
1415
1416 $this->bounce_to_email = self::combineNameEmail('', $email);
1417
1418 return $this;
1419 }
1420
1421 /**
1422 * Adds the `From:` email address to the email
1423 *
1424 * @param string $email The email address being sent from
1425 * @param string $name The from email user's name - unfortunately on windows this is ignored
1426 * @return fEmail The email object, to allow for method chaining
1427 */
1428 public function setFromEmail($email, $name = NULL) {
1429 if (!$email) {
1430 return;
1431 }
1432
1433 $this->from_email = self::combineNameEmail($name, $email);
1434
1435 return $this;
1436 }
1437
1438 /**
1439 * Sets the HTML version of the email body
1440 *
1441 * This method accepts either ASCII or UTF-8 encoded text. Please see
1442 * http://flourishlib.com/docs/UTF-8 for more information.
1443 *
1444 * @param string $html The HTML version of the email body
1445 * @return fEmail The email object, to allow for method chaining
1446 */
1447 public function setHTMLBody($html) {
1448 $this->html_body = $html;
1449
1450 return $this;
1451 }
1452
1453 /**
1454 * Adds the `Reply-To:` email address to the email
1455 *
1456 * @param string $email The email address to reply to
1457 * @param string $name The reply-to email user's name
1458 * @return fEmail The email object, to allow for method chaining
1459 */
1460 public function setReplyToEmail($email, $name = NULL) {
1461 if (!$email) {
1462 return;
1463 }
1464
1465 $this->reply_to_email = self::combineNameEmail($name, $email);
1466
1467 return $this;
1468 }
1469
1470 /**
1471 * Adds the `Sender:` email address to the email
1472 *
1473 * The `Sender:` header is used to indicate someone other than the `From:`
1474 * address is actually submitting the message to the network.
1475 *
1476 * @param string $email The email address the message is actually being sent from
1477 * @param string $name The sender email user's name
1478 * @return fEmail The email object, to allow for method chaining
1479 */
1480 public function setSenderEmail($email, $name = NULL) {
1481 if (!$email) {
1482 return;
1483 }
1484
1485 $this->sender_email = self::combineNameEmail($name, $email);
1486
1487 return $this;
1488 }
1489
1490 /**
1491 * Sets the subject of the email
1492 *
1493 * This method accepts either ASCII or UTF-8 encoded text. Please see
1494 * http://flourishlib.com/docs/UTF-8 for more information.
1495 *
1496 * @param string $subject The subject of the email
1497 * @return fEmail The email object, to allow for method chaining
1498 */
1499 public function setSubject($subject) {
1500 $this->subject = $subject;
1501
1502 return $this;
1503 }
1504
1505 /**
1506 * Sets the email to be signed with S/MIME
1507 *
1508 * @param string $senders_smime_cert_file The file path to the sender's PEM-encoded S/MIME certificate
1509 * @param string $senders_smime_pk_file The file path to the sender's S/MIME private key
1510 * @param string $senders_smime_pk_password The password for the sender's S/MIME private key
1511 * @return fEmail The email object, to allow for method chaining
1512 */
1513 public function sign($senders_smime_cert_file, $senders_smime_pk_file, $senders_smime_pk_password) {
1514 if (!extension_loaded('openssl')) {
1515 throw new fEnvironmentException('An S/MIME signature was requested for an email, but the %s extension is not installed', 'openssl');
1516 }
1517
1518 if (!self::stringlike($senders_smime_cert_file)) {
1519 throw new fProgrammerException("The sender's S/MIME certificate file specified, %s, does not appear to be a valid filename", $senders_smime_cert_file);
1520 }
1521 if (!file_exists($senders_smime_cert_file) || !is_readable($senders_smime_cert_file)) {
1522 throw new fEnvironmentException("The sender's S/MIME certificate file specified, %s, does not exist or could not be read", $senders_smime_cert_file);
1523 }
1524
1525 if (!self::stringlike($senders_smime_pk_file)) {
1526 throw new fProgrammerException("The sender's S/MIME primary key file specified, %s, does not appear to be a valid filename", $senders_smime_pk_file);
1527 }
1528 if (!file_exists($senders_smime_pk_file) || !is_readable($senders_smime_pk_file)) {
1529 throw new fEnvironmentException("The sender's S/MIME primary key file specified, %s, does not exist or could not be read", $senders_smime_pk_file);
1530 }
1531
1532 $this->smime_sign = TRUE;
1533 $this->senders_smime_cert_file = $senders_smime_cert_file;
1534 $this->senders_smime_pk_file = $senders_smime_pk_file;
1535 $this->senders_smime_pk_password = $senders_smime_pk_password;
1536
1537 return $this;
1538 }
1539
1540 /**
1541 * Validates that all of the parts of the email are valid
1542 *
1543 * @throws fValidationException When part of the email is missing or formatted incorrectly
1544 *
1545 * @return void
1546 */
1547 private function validate() {
1548 $validation_messages = array();
1549
1550 // Check all multi-address email field
1551 $multi_address_field_list = array(
1552 'to_emails' => self::compose('recipient'),
1553 'cc_emails' => self::compose('CC recipient'),
1554 'bcc_emails' => self::compose('BCC recipient')
1555 );
1556
1557 foreach ($multi_address_field_list as $field => $name) {
1558 foreach ($this->$field as $email) {
1559 if ($email && !preg_match(self::NAME_EMAIL_REGEX, $email) && !preg_match(self::EMAIL_REGEX, $email)) {
1560 $validation_messages[] = htmlspecialchars(self::compose(
1561 'The %1$s %2$s is not a valid email address. Should be like "John Smith" <name@example.com> or name@example.com.', $name, $email
1562 ), ENT_QUOTES, 'UTF-8');
1563 }
1564 }
1565 }
1566
1567 // Check all single-address email fields
1568 $single_address_field_list = array(
1569 'from_email' => self::compose('From email address'),
1570 'reply_to_email' => self::compose('Reply-To email address'),
1571 'sender_email' => self::compose('Sender email address'),
1572 'bounce_to_email' => self::compose('Bounce-To email address')
1573 );
1574
1575 foreach ($single_address_field_list as $field => $name) {
1576 if ($this->$field && !preg_match(self::NAME_EMAIL_REGEX, $this->$field) && !preg_match(self::EMAIL_REGEX, $this->$field)) {
1577 $validation_messages[] = htmlspecialchars(self::compose(
1578 'The %1$s %2$s is not a valid email address. Should be like "John Smith" <name@example.com> or name@example.com.', $name, $this->$field
1579 ), ENT_QUOTES, 'UTF-8');
1580 }
1581 }
1582
1583 // Make sure the required fields are all set
1584 if (!$this->to_emails) {
1585 $validation_messages[] = self::compose(
1586 "Please provide at least one recipient"
1587 );
1588 }
1589
1590 if (!$this->from_email) {
1591 $validation_messages[] = self::compose(
1592 "Please provide the from email address"
1593 );
1594 }
1595
1596 if (!self::stringlike($this->subject)) {
1597 $validation_messages[] = self::compose(
1598 "Please provide an email subject"
1599 );
1600 }
1601
1602 if (strpos($this->subject, "\n") !== FALSE) {
1603 $validation_messages[] = self::compose(
1604 "The subject contains one or more newline characters"
1605 );
1606 }
1607
1608 if (!self::stringlike($this->plaintext_body)) {
1609 $validation_messages[] = self::compose(
1610 "Please provide a plaintext email body"
1611 );
1612 }
1613
1614 // Make sure the attachments look good
1615 foreach ($this->attachments as $filename => $file_info) {
1616 if (!self::stringlike($file_info['mime-type'])) {
1617 $validation_messages[] = self::compose(
1618 "No mime-type was specified for the attachment %s", $filename
1619 );
1620 }
1621 if (!self::stringlike($file_info['contents'])) {
1622 $validation_messages[] = self::compose(
1623 "The attachment %s appears to be a blank file", $filename
1624 );
1625 }
1626 }
1627
1628 if ($validation_messages) {
1629 throw new fValidationException('The email could not be sent because:', $validation_messages);
1630 }
1631 }
1632
1633 }
1634
1635 /**
1636 * Copyright (c) 2008-2011 Will Bond <will@flourishlib.com>, others
1637 *
1638 * Permission is hereby granted, free of charge, to any person obtaining a copy
1639 * of this software and associated documentation files (the "Software"), to deal
1640 * in the Software without restriction, including without limitation the rights
1641 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1642 * copies of the Software, and to permit persons to whom the Software is
1643 * furnished to do so, subject to the following conditions:
1644 *
1645 * The above copyright notice and this permission notice shall be included in
1646 * all copies or substantial portions of the Software.
1647 *
1648 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1649 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1650 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1651 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1652 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1653 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
1654 * THE SOFTWARE.
1655 */