mimemail.inc 18 KB

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