mimemail.inc 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. <?php
  2. /**
  3. * @file
  4. * Common mail functions for sending e-mail. Originally written by Gerhard.
  5. *
  6. * Allie Micka <allie at pajunas dot com>
  7. */
  8. /**
  9. * Attempts to RFC822-compliant headers for the mail message or its MIME parts.
  10. *
  11. * @todo Could use some enhancement and stress testing.
  12. *
  13. * @param array $headers
  14. * An array of headers.
  15. *
  16. * @return string
  17. * A string containing the headers.
  18. */
  19. function mimemail_rfc_headers($headers) {
  20. $header = '';
  21. $crlf = variable_get('mimemail_crlf', MAIL_LINE_ENDINGS);
  22. foreach ($headers as $key => $value) {
  23. $key = trim($key);
  24. // Collapse spaces and get rid of newline characters.
  25. $value = preg_replace('/(\s+|\n|\r|^\s|\s$)/', ' ', $value);
  26. // Fold headers if they're too long.
  27. // A CRLF may be inserted before any WSP.
  28. // @see http://tools.ietf.org/html/rfc2822#section-2.2.3
  29. if (drupal_strlen($value) > 60) {
  30. // If there's a semicolon, use that to separate.
  31. if (count($array = preg_split('/;\s*/', $value)) > 1) {
  32. $value = trim(join(";$crlf ", $array));
  33. }
  34. else {
  35. $value = wordwrap($value, 50, "$crlf ", FALSE);
  36. }
  37. }
  38. $header .= $key . ":" . $value . $crlf;
  39. }
  40. return trim($header);
  41. }
  42. /**
  43. * Gives useful defaults for standard email headers.
  44. *
  45. * @param array $headers
  46. * Message headers.
  47. * @param string $from
  48. * The address of the sender.
  49. *
  50. * @return array
  51. * Overwrited headers.
  52. */
  53. function mimemail_headers($headers, $from = NULL) {
  54. $default_from = variable_get('site_mail', ini_get('sendmail_from'));
  55. // Overwrite standard headers.
  56. if ($from) {
  57. if (!isset($headers['From']) || $headers['From'] == $default_from) {
  58. $headers['From'] = $from;
  59. }
  60. if (!isset($headers['Sender']) || $headers['Sender'] == $default_from) {
  61. $headers['Sender'] = $from;
  62. }
  63. // This may not work. The MTA may rewrite the Return-Path.
  64. if (!isset($headers['Return-Path']) || $headers['Return-Path'] == $default_from) {
  65. if (preg_match('/[a-z\d\-\.\+_]+@(?:[a-z\d\-]+\.)+[a-z\d]{2,4}/i', $from, $matches)) {
  66. $headers['Return-Path'] = "<$matches[0]>";
  67. }
  68. }
  69. }
  70. // Convert From header if it is an array.
  71. if (is_array($headers['From'])) {
  72. $headers['From'] = mimemail_address($headers['From']);
  73. }
  74. // Run all headers through mime_header_encode() to convert non-ascii
  75. // characters to an rfc compliant string, similar to drupal_mail().
  76. foreach ($headers as $key => $value) {
  77. $headers[$key] = mime_header_encode($value);
  78. }
  79. return $headers;
  80. }
  81. /**
  82. * Extracts links to local images from HTML documents.
  83. *
  84. * @param string $html
  85. * A string containing the HTML source of the message.
  86. *
  87. * @return array
  88. * An array containing the document body and the extracted files like the following.
  89. * array(
  90. * array(
  91. * 'name' => document name
  92. * 'content' => html text, local image urls replaced by Content-IDs,
  93. * 'Content-Type' => 'text/html; charset=utf-8')
  94. * array(
  95. * 'name' => file name,
  96. * 'file' => reference to local file,
  97. * 'Content-ID' => generated Content-ID,
  98. * 'Content-Type' => derived using mime_content_type if available, educated guess otherwise
  99. * )
  100. * )
  101. */
  102. function mimemail_extract_files($html) {
  103. $pattern = '/(<link[^>]+href=[\'"]?|<object[^>]+codebase=[\'"]?|@import |[\s]src=[\'"]?)([^\'>"]+)([\'"]?)/mis';
  104. $content = preg_replace_callback($pattern, '_mimemail_replace_files', $html);
  105. $encoding = '8Bit';
  106. $body = explode("\n", $content);
  107. foreach ($body as $line) {
  108. if (drupal_strlen($line) > 998) {
  109. $encoding = 'base64';
  110. break;
  111. }
  112. }
  113. if ($encoding == 'base64') {
  114. $content = rtrim(chunk_split(base64_encode($content)));
  115. }
  116. $document = array(array(
  117. 'Content-Type' => "text/html; charset=utf-8",
  118. 'Content-Transfer-Encoding' => $encoding,
  119. 'content' => $content,
  120. ));
  121. $files = _mimemail_file();
  122. return array_merge($document, $files);
  123. }
  124. /**
  125. * Callback function for preg_replace_callback().
  126. */
  127. function _mimemail_replace_files($matches) {
  128. return stripslashes($matches[1]) . _mimemail_file($matches[2]) . stripslashes($matches[3]);
  129. }
  130. /**
  131. * Helper function to extract local files.
  132. *
  133. * @param string $url
  134. * (optional) The URI or the absolute URL to the file.
  135. * @param string $content
  136. * (optional) The actual file content.
  137. * @param string $name
  138. * (optional) The file name.
  139. * @param string $type
  140. * (optional) The file type.
  141. * @param string $disposition
  142. * (optional) The content disposition. Defaults to inline.
  143. *
  144. * @return
  145. * The Content-ID and/or an array of the files on success or the URL on failure.
  146. */
  147. function _mimemail_file($url = NULL, $content = NULL, $name = '', $type = '', $disposition = 'inline') {
  148. static $files = array();
  149. static $ids = array();
  150. if ($url) {
  151. $image = preg_match('!\.(png|gif|jpg|jpeg)$!i', $url);
  152. $linkonly = variable_get('mimemail_linkonly', 0);
  153. // The file exists on the server as-is. Allows for non-web-accessible files.
  154. if (@is_file($url) && $image && !$linkonly) {
  155. $file = $url;
  156. }
  157. else {
  158. $url = _mimemail_url($url, 'TRUE');
  159. // The $url is absolute, we're done here.
  160. $scheme = file_uri_scheme($url);
  161. if ($scheme == 'http' || $scheme == 'https' || preg_match('!mailto:!', $url)) {
  162. return $url;
  163. }
  164. // The $url is a non-local URI that needs to be converted to a URL.
  165. else {
  166. $file = (drupal_realpath($url)) ? drupal_realpath($url) : file_create_url($url);
  167. }
  168. }
  169. }
  170. // We have the actual content.
  171. elseif ($content) {
  172. $file = $content;
  173. }
  174. if (isset($file) && (@is_file($file) || $content)) {
  175. $public_path = file_default_scheme() . '://';
  176. $no_access = !user_access('send arbitrary files');
  177. $not_in_public_path = strpos(drupal_realpath($file), drupal_realpath($public_path)) !== 0;
  178. if (@is_file($file) && $not_in_public_path && $no_access) {
  179. return $url;
  180. }
  181. if (!$name) {
  182. $name = (@is_file($file)) ? basename($file) : 'attachment.dat';
  183. }
  184. if (!$type) {
  185. $type = ($name) ? file_get_mimetype($name) : file_get_mimetype($file);
  186. }
  187. $id = md5($file) .'@'. $_SERVER['HTTP_HOST'];
  188. // Prevent duplicate items.
  189. if (isset($ids[$id])) {
  190. return 'cid:'. $ids[$id];
  191. }
  192. $new_file = array(
  193. 'name' => $name,
  194. 'file' => $file,
  195. 'Content-ID' => $id,
  196. 'Content-Disposition' => $disposition,
  197. 'Content-Type' => $type,
  198. );
  199. $files[] = $new_file;
  200. $ids[$id] = $id;
  201. return 'cid:' . $id;
  202. }
  203. // The $file does not exist and no $content, return the $url if possible.
  204. elseif ($url) {
  205. return $url;
  206. }
  207. $ret = $files;
  208. $files = array();
  209. $ids = array();
  210. return $ret;
  211. }
  212. /**
  213. * Build a multipart body.
  214. *
  215. * @param array $parts
  216. * An associative array containing the parts to be included:
  217. * - name: A string containing the name of the attachment.
  218. * - content: A string containing textual content.
  219. * - file: A string containing file content.
  220. * - Content-Type: A string containing the content type of either file or content. Mandatory
  221. * for content, optional for file. If not present, it will be derived from file the file if
  222. * mime_content_type is available. If not, application/octet-stream is used.
  223. * - Content-Disposition: (optional) A string containing the disposition. Defaults to inline.
  224. * - Content-Transfer-Encoding: (optional) Base64 is assumed for files, 8bit for other content.
  225. * - Content-ID: (optional) for in-mail references to attachements.
  226. * Name is mandatory, one of content and file is required, they are mutually exclusive.
  227. * @param string $content_type
  228. * (optional) A string containing the content-type for the combined message. Defaults to
  229. * multipart/mixed.
  230. *
  231. * @return array
  232. * An associative array containing the following elements:
  233. * - body: A string containing the MIME-encoded multipart body of a mail.
  234. * - headers: An array that includes some headers for the mail to be sent.
  235. */
  236. function mimemail_multipart_body($parts, $content_type = 'multipart/mixed; charset=utf-8', $sub_part = FALSE) {
  237. // Control variable to avoid boundary collision.
  238. static $part_num = 0;
  239. $boundary = sha1(uniqid($_SERVER['REQUEST_TIME'], TRUE)) . $part_num++;
  240. $body = '';
  241. $headers = array(
  242. 'Content-Type' => "$content_type; boundary=\"$boundary\"",
  243. );
  244. if (!$sub_part) {
  245. $headers['MIME-Version'] = '1.0';
  246. $body = "This is a multi-part message in MIME format.\n";
  247. }
  248. foreach ($parts as $part) {
  249. $part_headers = array();
  250. if (isset($part['Content-ID'])) {
  251. $part_headers['Content-ID'] = '<' . $part['Content-ID'] . '>';
  252. }
  253. if (isset($part['Content-Type'])) {
  254. $part_headers['Content-Type'] = $part['Content-Type'];
  255. }
  256. if (isset($part['Content-Disposition'])) {
  257. $part_headers['Content-Disposition'] = $part['Content-Disposition'];
  258. }
  259. elseif (strpos($part['Content-Type'], 'multipart/alternative') === FALSE) {
  260. $part_headers['Content-Disposition'] = 'inline';
  261. }
  262. if (isset($part['Content-Transfer-Encoding'])) {
  263. $part_headers['Content-Transfer-Encoding'] = $part['Content-Transfer-Encoding'];
  264. }
  265. // Mail content provided as a string.
  266. if (isset($part['content']) && $part['content']) {
  267. if (!isset($part['Content-Transfer-Encoding'])) {
  268. $part_headers['Content-Transfer-Encoding'] = '8bit';
  269. }
  270. $part_body = $part['content'];
  271. if (isset($part['name'])) {
  272. $part_headers['Content-Type'] .= '; name="' . $part['name'] . '"';
  273. $part_headers['Content-Disposition'] .= '; filename="' . $part['name'] . '"';
  274. }
  275. // Mail content references in a filename.
  276. }
  277. else {
  278. if (!isset($part['Content-Transfer-Encoding'])) {
  279. $part_headers['Content-Transfer-Encoding'] = 'base64';
  280. }
  281. if (!isset($part['Content-Type'])) {
  282. $part['Content-Type'] = file_get_mimetype($part['file']);
  283. }
  284. if (isset($part['name'])) {
  285. $part_headers['Content-Type'] .= '; name="' . $part['name'] . '"';
  286. $part_headers['Content-Disposition'] .= '; filename="' . $part['name'] . '"';
  287. }
  288. if (isset($part['file'])) {
  289. $file = (is_file($part['file'])) ? file_get_contents($part['file']) : $part['file'];
  290. $part_body = chunk_split(base64_encode($file), 76, variable_get('mimemail_crlf', "\n"));
  291. }
  292. }
  293. $body .= "\n--$boundary\n";
  294. $body .= mimemail_rfc_headers($part_headers) . "\n\n";
  295. $body .= isset($part_body) ? $part_body : '';
  296. }
  297. $body .= "\n--$boundary--\n";
  298. return array('headers' => $headers, 'body' => $body);
  299. }
  300. /**
  301. * Callback for preg_replace_callback().
  302. */
  303. function _mimemail_expand_links($matches) {
  304. return $matches[1] . _mimemail_url($matches[2]);
  305. }
  306. /**
  307. * Generate a multipart message body with a text alternative for some HTML text.
  308. *
  309. * @param string $body
  310. * The HTML message body.
  311. * @param string $subject
  312. * The message subject.
  313. * @param boolean $plain
  314. * (optional) Whether the recipient prefers plaintext-only messages. Defaults to FALSE.
  315. * @param string $plaintext
  316. * (optional) The plaintext message body.
  317. * @param array $attachments
  318. * (optional) The files to be attached to the message.
  319. *
  320. * @return array
  321. * An associative array containing the following elements:
  322. * - body: A string containing the MIME-encoded multipart body of a mail.
  323. * - headers: An array that includes some headers for the mail to be sent.
  324. *
  325. * The first mime part is a multipart/alternative containing mime-encoded sub-parts for
  326. * HTML and plaintext. Each subsequent part is the required image or attachment.
  327. */
  328. function mimemail_html_body($body, $subject, $plain = FALSE, $plaintext = NULL, $attachments = array()) {
  329. if (empty($plaintext)) {
  330. // @todo Remove once filter_xss() can handle direct descendant selectors in inline CSS.
  331. // @see http://drupal.org/node/1116930
  332. // @see http://drupal.org/node/370903
  333. // Pull out the message body.
  334. preg_match('|<body.*?</body>|mis', $body, $matches);
  335. $plaintext = drupal_html_to_text($matches[0]);
  336. }
  337. if ($plain) {
  338. // Plain mail without attachment.
  339. if (empty($attachments)) {
  340. $content_type = 'text/plain';
  341. return array(
  342. 'body' => $plaintext,
  343. 'headers' => array('Content-Type' => 'text/plain; charset=utf-8'),
  344. );
  345. }
  346. // Plain mail with attachement.
  347. else {
  348. $content_type = 'multipart/mixed';
  349. $parts = array(array(
  350. 'content' => $plaintext,
  351. 'Content-Type' => 'text/plain; charset=utf-8',
  352. ));
  353. }
  354. }
  355. else {
  356. $content_type = 'multipart/mixed';
  357. $plaintext_part = array('Content-Type' => 'text/plain; charset=utf-8', 'content' => $plaintext);
  358. // Expand all local links.
  359. $pattern = '/(<a[^>]+href=")([^"]*)/mi';
  360. $body = preg_replace_callback($pattern, '_mimemail_expand_links', $body);
  361. $mime_parts = mimemail_extract_files($body);
  362. $content = array($plaintext_part, array_shift($mime_parts));
  363. $content = mimemail_multipart_body($content, 'multipart/alternative', TRUE);
  364. $parts = array(array('Content-Type' => $content['headers']['Content-Type'], 'content' => $content['body']));
  365. if ($mime_parts) {
  366. $parts = array_merge($parts, $mime_parts);
  367. $content = mimemail_multipart_body($parts, 'multipart/related; type="multipart/alternative"', TRUE);
  368. $parts = array(array('Content-Type' => $content['headers']['Content-Type'], 'content' => $content['body']));
  369. }
  370. }
  371. if (is_array($attachments) && !empty($attachments)) {
  372. foreach ($attachments as $a) {
  373. $a = (object) $a;
  374. $path = isset($a->uri) ? $a->uri : (isset($a->filepath) ? $a->filepath : NULL);
  375. $content = isset($a->filecontent) ? $a->filecontent : NULL;
  376. $name = isset($a->filename) ? $a->filename : NULL;
  377. $type = isset($a->filemime) ? $a->filemime : NULL;
  378. _mimemail_file($path, $content, $name, $type, 'attachment');
  379. $parts = array_merge($parts, _mimemail_file());
  380. }
  381. }
  382. return mimemail_multipart_body($parts, $content_type);
  383. }
  384. /**
  385. * Helper function to format URLs.
  386. *
  387. * @param string $url
  388. * The file path.
  389. * @param boolean $to_embed
  390. * (optional) Wheter the URL is used to embed the file. Defaults to NULL.
  391. *
  392. * @return string
  393. * A processed URL.
  394. */
  395. function _mimemail_url($url, $to_embed = NULL) {
  396. global $base_url;
  397. $url = urldecode($url);
  398. $to_link = variable_get('mimemail_linkonly', 0);
  399. $is_image = preg_match('!\.(png|gif|jpg|jpeg)!i', $url);
  400. $is_absolute = file_uri_scheme($url) != FALSE || preg_match('!(mailto|callto|tel)\:!', $url);
  401. if (!$to_embed) {
  402. if ($is_absolute) {
  403. return str_replace(' ', '%20', $url);
  404. }
  405. }
  406. else {
  407. $url = preg_replace('!^' . base_path() . '!', '', $url, 1);
  408. if ($is_image) {
  409. if ($to_link) {
  410. // Exclude images from embedding if needed.
  411. $url = file_create_url($url);
  412. $url = str_replace(' ', '%20', $url);
  413. }
  414. else {
  415. // Remove security token from URL, this allows for styled image embedding.
  416. // @see https://drupal.org/drupal-7.20-release-notes
  417. $url = preg_replace('/\\?itok=.*$/', '', $url);
  418. }
  419. }
  420. return $url;
  421. }
  422. $url = str_replace('?q=', '', $url);
  423. @list($url, $fragment) = explode('#', $url, 2);
  424. @list($path, $query) = explode('?', $url, 2);
  425. // If we're dealing with an intra-document reference, return it.
  426. if (empty($path)) {
  427. return '#' . $fragment;
  428. }
  429. // Get a list of enabled languages.
  430. $languages = language_list('enabled');
  431. $languages = $languages[1];
  432. // Default language settings.
  433. $prefix = '';
  434. $language = language_default();
  435. // Check for language prefix.
  436. $args = explode('/', $path);
  437. foreach ($languages as $lang) {
  438. if ($args[0] == $lang->prefix) {
  439. $prefix = array_shift($args);
  440. $language = $lang;
  441. $path = implode('/', $args);
  442. break;
  443. }
  444. }
  445. $options = array(
  446. 'query' => ($query) ? drupal_get_query_array($query) : array(),
  447. 'fragment' => $fragment,
  448. 'absolute' => TRUE,
  449. 'language' => $language,
  450. 'prefix' => $prefix,
  451. );
  452. $url = url($path, $options);
  453. // If url() added a ?q= where there should not be one, remove it.
  454. if (preg_match('!^\?q=*!', $url)) {
  455. $url = preg_replace('!\?q=!', '', $url);
  456. }
  457. $url = str_replace('+', '%2B', $url);
  458. return $url;
  459. }
  460. /**
  461. * Formats an address string.
  462. *
  463. * @todo Could use some enhancement and stress testing.
  464. *
  465. * @param mixed $address
  466. * A user object, a text email address or an array containing name, mail.
  467. * @param boolean $simplify
  468. * Determines if the address needs to be simplified. Defaults to FALSE.
  469. *
  470. * @return string
  471. * A formatted address string or FALSE.
  472. */
  473. function mimemail_address($address, $simplify = FALSE) {
  474. if (is_array($address)) {
  475. // It's an array containing 'mail' and/or 'name'.
  476. if (isset($address['mail'])) {
  477. $output = '';
  478. if (empty($address['name']) || $simplify) {
  479. return $address['mail'];
  480. }
  481. else {
  482. return '"' . addslashes(mime_header_encode($address['name'])) . '" <' . $address['mail'] . '>';
  483. }
  484. }
  485. // It's an array of address items.
  486. $addresses = array();
  487. foreach ($address as $a) {
  488. $addresses[] = mimemail_address($a);
  489. }
  490. return $addresses;
  491. }
  492. // It's a user object.
  493. if (is_object($address) && isset($address->mail)) {
  494. if (empty($address->name) || $simplify) {
  495. return $address->mail;
  496. }
  497. else {
  498. return '"' . addslashes(mime_header_encode($address->name)) . '" <' . $address->mail . '>';
  499. }
  500. }
  501. // It's formatted or unformatted string.
  502. // @todo: shouldn't assume it's valid - should try to re-parse
  503. if (is_string($address)) {
  504. return $address;
  505. }
  506. return FALSE;
  507. }