PageRenderTime 56ms CodeModel.GetById 22ms RepoModel.GetById 1ms app.codeStats 0ms

/classphp/flourish/fEmail.php

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