1<?php 2/** 3 * A class to build and send multi part mails (with HTML content and embedded 4 * attachments). All mails are assumed to be in UTF-8 encoding. 5 * 6 * Attachments are handled in memory so this shouldn't be used to send huge 7 * files, but then again mail shouldn't be used to send huge files either. 8 * 9 * @author Andreas Gohr <andi@splitbrain.org> 10 */ 11 12// end of line for mail lines - RFC822 says CRLF but postfix (and other MTAs?) 13// think different 14if(!defined('MAILHEADER_EOL')) define('MAILHEADER_EOL', "\n"); 15#define('MAILHEADER_ASCIIONLY',1); 16 17/** 18 * Mail Handling 19 */ 20class Mailer { 21 22 protected $headers = array(); 23 protected $attach = array(); 24 protected $html = ''; 25 protected $text = ''; 26 27 protected $boundary = ''; 28 protected $partid = ''; 29 protected $sendparam = null; 30 31 /** @var EmailAddressValidator */ 32 protected $validator = null; 33 protected $allowhtml = true; 34 35 /** 36 * Constructor 37 * 38 * Initializes the boundary strings and part counters 39 */ 40 public function __construct() { 41 global $conf; 42 43 $server = parse_url(DOKU_URL, PHP_URL_HOST); 44 45 $this->partid = md5(uniqid(rand(), true)).'@'.$server; 46 $this->boundary = '----------'.md5(uniqid(rand(), true)); 47 48 $listid = join('.', array_reverse(explode('/', DOKU_BASE))).$server; 49 $listid = strtolower(trim($listid, '.')); 50 51 $this->allowhtml = (bool)$conf['htmlmail']; 52 53 // add some default headers for mailfiltering FS#2247 54 $this->setHeader('X-Mailer', 'DokuWiki '.getVersion()); 55 $this->setHeader('X-DokuWiki-User', $_SERVER['REMOTE_USER']); 56 $this->setHeader('X-DokuWiki-Title', $conf['title']); 57 $this->setHeader('X-DokuWiki-Server', $server); 58 $this->setHeader('X-Auto-Response-Suppress', 'OOF'); 59 $this->setHeader('List-Id', $conf['title'].' <'.$listid.'>'); 60 } 61 62 /** 63 * Attach a file 64 * 65 * @param string $path Path to the file to attach 66 * @param string $mime Mimetype of the attached file 67 * @param string $name The filename to use 68 * @param string $embed Unique key to reference this file from the HTML part 69 */ 70 public function attachFile($path, $mime, $name = '', $embed = '') { 71 if(!$name) { 72 $name = utf8_basename($path); 73 } 74 75 $this->attach[] = array( 76 'data' => file_get_contents($path), 77 'mime' => $mime, 78 'name' => $name, 79 'embed' => $embed 80 ); 81 } 82 83 /** 84 * Attach a file 85 * 86 * @param string $data The file contents to attach 87 * @param string $mime Mimetype of the attached file 88 * @param string $name The filename to use 89 * @param string $embed Unique key to reference this file from the HTML part 90 */ 91 public function attachContent($data, $mime, $name = '', $embed = '') { 92 if(!$name) { 93 list(, $ext) = explode('/', $mime); 94 $name = count($this->attach).".$ext"; 95 } 96 97 $this->attach[] = array( 98 'data' => $data, 99 'mime' => $mime, 100 'name' => $name, 101 'embed' => $embed 102 ); 103 } 104 105 /** 106 * Callback function to automatically embed images referenced in HTML templates 107 */ 108 protected function autoembed_cb($matches) { 109 static $embeds = 0; 110 $embeds++; 111 112 // get file and mime type 113 $media = cleanID($matches[1]); 114 list(, $mime) = mimetype($media); 115 $file = mediaFN($media); 116 if(!file_exists($file)) return $matches[0]; //bad reference, keep as is 117 118 // attach it and set placeholder 119 $this->attachFile($file, $mime, '', 'autoembed'.$embeds); 120 return '%%autoembed'.$embeds.'%%'; 121 } 122 123 /** 124 * Add an arbitrary header to the mail 125 * 126 * If an empy value is passed, the header is removed 127 * 128 * @param string $header the header name (no trailing colon!) 129 * @param string $value the value of the header 130 * @param bool $clean remove all non-ASCII chars and line feeds? 131 */ 132 public function setHeader($header, $value, $clean = true) { 133 $header = str_replace(' ', '-', ucwords(strtolower(str_replace('-', ' ', $header)))); // streamline casing 134 if($clean) { 135 $header = preg_replace('/[^a-zA-Z0-9_ \-\.\+\@]+/', '', $header); 136 $value = preg_replace('/[^a-zA-Z0-9_ \-\.\+\@<>]+/', '', $value); 137 } 138 139 // empty value deletes 140 $value = trim($value); 141 if($value === '') { 142 if(isset($this->headers[$header])) unset($this->headers[$header]); 143 } else { 144 $this->headers[$header] = $value; 145 } 146 } 147 148 /** 149 * Set additional parameters to be passed to sendmail 150 * 151 * Whatever is set here is directly passed to PHP's mail() command as last 152 * parameter. Depending on the PHP setup this might break mailing alltogether 153 */ 154 public function setParameters($param) { 155 $this->sendparam = $param; 156 } 157 158 /** 159 * Set the text and HTML body and apply replacements 160 * 161 * This function applies a whole bunch of default replacements in addition 162 * to the ones specidifed as parameters 163 * 164 * If you pass the HTML part or HTML replacements yourself you have to make 165 * sure you encode all HTML special chars correctly 166 * 167 * @param string $text plain text body 168 * @param array $textrep replacements to apply on the text part 169 * @param array $htmlrep replacements to apply on the HTML part, leave null to use $textrep 170 * @param array $html the HTML body, leave null to create it from $text 171 * @param bool $wrap wrap the HTML in the default header/Footer 172 */ 173 public function setBody($text, $textrep = null, $htmlrep = null, $html = null, $wrap = true) { 174 global $INFO; 175 global $conf; 176 $htmlrep = (array)$htmlrep; 177 $textrep = (array)$textrep; 178 179 // create HTML from text if not given 180 if(is_null($html)) { 181 $html = $text; 182 $html = hsc($html); 183 $html = preg_replace('/^-----*$/m', '<hr >', $html); 184 $html = nl2br($html); 185 } 186 if($wrap) { 187 $wrap = rawLocale('mailwrap', 'html'); 188 $html = preg_replace('/\n-- <br \/>.*$/s', '', $html); //strip signature 189 $html = str_replace('@HTMLBODY@', $html, $wrap); 190 } 191 192 // copy over all replacements missing for HTML (autolink URLs) 193 foreach($textrep as $key => $value) { 194 if(isset($htmlrep[$key])) continue; 195 if(preg_match('/^https?:\/\//i', $value)) { 196 $htmlrep[$key] = '<a href="'.hsc($value).'">'.hsc($value).'</a>'; 197 } else { 198 $htmlrep[$key] = hsc($value); 199 } 200 } 201 202 // embed media from templates 203 $html = preg_replace_callback( 204 '/@MEDIA\(([^\)]+)\)@/', 205 array($this, 'autoembed_cb'), $html 206 ); 207 208 // prepare default replacements 209 $ip = clientIP(); 210 $cip = gethostsbyaddrs($ip); 211 $trep = array( 212 'DATE' => dformat(), 213 'BROWSER' => $_SERVER['HTTP_USER_AGENT'], 214 'IPADDRESS' => $ip, 215 'HOSTNAME' => $cip, 216 'TITLE' => $conf['title'], 217 'DOKUWIKIURL' => DOKU_URL, 218 'USER' => $_SERVER['REMOTE_USER'], 219 'NAME' => $INFO['userinfo']['name'], 220 'MAIL' => $INFO['userinfo']['mail'], 221 ); 222 $trep = array_merge($trep, (array)$textrep); 223 $hrep = array( 224 'DATE' => '<i>'.hsc(dformat()).'</i>', 225 'BROWSER' => hsc($_SERVER['HTTP_USER_AGENT']), 226 'IPADDRESS' => '<code>'.hsc($ip).'</code>', 227 'HOSTNAME' => '<code>'.hsc($cip).'</code>', 228 'TITLE' => hsc($conf['title']), 229 'DOKUWIKIURL' => '<a href="'.DOKU_URL.'">'.DOKU_URL.'</a>', 230 'USER' => hsc($_SERVER['REMOTE_USER']), 231 'NAME' => hsc($INFO['userinfo']['name']), 232 'MAIL' => '<a href="mailto:"'.hsc($INFO['userinfo']['mail']).'">'. 233 hsc($INFO['userinfo']['mail']).'</a>', 234 ); 235 $hrep = array_merge($hrep, (array)$htmlrep); 236 237 // Apply replacements 238 foreach($trep as $key => $substitution) { 239 $text = str_replace('@'.strtoupper($key).'@', $substitution, $text); 240 } 241 foreach($hrep as $key => $substitution) { 242 $html = str_replace('@'.strtoupper($key).'@', $substitution, $html); 243 } 244 245 $this->setHTML($html); 246 $this->setText($text); 247 } 248 249 /** 250 * Set the HTML part of the mail 251 * 252 * Placeholders can be used to reference embedded attachments 253 * 254 * You probably want to use setBody() instead 255 */ 256 public function setHTML($html) { 257 $this->html = $html; 258 } 259 260 /** 261 * Set the plain text part of the mail 262 * 263 * You probably want to use setBody() instead 264 */ 265 public function setText($text) { 266 $this->text = $text; 267 } 268 269 /** 270 * Add the To: recipients 271 * 272 * @see setAddress 273 * @param string $address Multiple adresses separated by commas 274 */ 275 public function to($address) { 276 $this->setHeader('To', $address, false); 277 } 278 279 /** 280 * Add the Cc: recipients 281 * 282 * @see setAddress 283 * @param string $address Multiple adresses separated by commas 284 */ 285 public function cc($address) { 286 $this->setHeader('Cc', $address, false); 287 } 288 289 /** 290 * Add the Bcc: recipients 291 * 292 * @see setAddress 293 * @param string $address Multiple adresses separated by commas 294 */ 295 public function bcc($address) { 296 $this->setHeader('Bcc', $address, false); 297 } 298 299 /** 300 * Add the From: address 301 * 302 * This is set to $conf['mailfrom'] when not specified so you shouldn't need 303 * to call this function 304 * 305 * @see setAddress 306 * @param string $address from address 307 */ 308 public function from($address) { 309 $this->setHeader('From', $address, false); 310 } 311 312 /** 313 * Add the mail's Subject: header 314 * 315 * @param string $subject the mail subject 316 */ 317 public function subject($subject) { 318 $this->headers['Subject'] = $subject; 319 } 320 321 /** 322 * Sets an email address header with correct encoding 323 * 324 * Unicode characters will be deaccented and encoded base64 325 * for headers. Addresses may not contain Non-ASCII data! 326 * 327 * Example: 328 * setAddress("föö <foo@bar.com>, me@somewhere.com","TBcc"); 329 * 330 * @param string $address Multiple adresses separated by commas 331 * @return bool|string the prepared header (can contain multiple lines) 332 */ 333 public function cleanAddress($address) { 334 // No named recipients for To: in Windows (see FS#652) 335 $names = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') ? false : true; 336 337 $address = preg_replace('/[\r\n\0]+/', ' ', $address); // remove attack vectors 338 339 $headers = ''; 340 $parts = explode(',', $address); 341 foreach($parts as $part) { 342 $part = trim($part); 343 344 // parse address 345 if(preg_match('#(.*?)<(.*?)>#', $part, $matches)) { 346 $text = trim($matches[1]); 347 $addr = $matches[2]; 348 } else { 349 $addr = $part; 350 } 351 // skip empty ones 352 if(empty($addr)) { 353 continue; 354 } 355 356 // FIXME: is there a way to encode the localpart of a emailaddress? 357 if(!utf8_isASCII($addr)) { 358 msg(htmlspecialchars("E-Mail address <$addr> is not ASCII"), -1); 359 continue; 360 } 361 362 if(is_null($this->validator)) { 363 $this->validator = new EmailAddressValidator(); 364 $this->validator->allowLocalAddresses = true; 365 } 366 if(!$this->validator->check_email_address($addr)) { 367 msg(htmlspecialchars("E-Mail address <$addr> is not valid"), -1); 368 continue; 369 } 370 371 // text was given 372 if(!empty($text) && $names) { 373 // add address quotes 374 $addr = "<$addr>"; 375 376 if(defined('MAILHEADER_ASCIIONLY')) { 377 $text = utf8_deaccent($text); 378 $text = utf8_strip($text); 379 } 380 381 if(!utf8_isASCII($text)) { 382 $text = '=?UTF-8?B?'.base64_encode($text).'?='; 383 } 384 } else { 385 $text = ''; 386 } 387 388 // add to header comma seperated 389 if($headers != '') { 390 $headers .= ', '; 391 } 392 $headers .= $text.' '.$addr; 393 } 394 395 if(empty($headers)) return false; 396 397 return $headers; 398 } 399 400 401 /** 402 * Prepare the mime multiparts for all attachments 403 * 404 * Replaces placeholders in the HTML with the correct CIDs 405 */ 406 protected function prepareAttachments() { 407 $mime = ''; 408 $part = 1; 409 // embedded attachments 410 foreach($this->attach as $media) { 411 // create content id 412 $cid = 'part'.$part.'.'.$this->partid; 413 414 // replace wildcards 415 if($media['embed']) { 416 $this->html = str_replace('%%'.$media['embed'].'%%', 'cid:'.$cid, $this->html); 417 } 418 419 $mime .= '--'.$this->boundary.MAILHEADER_EOL; 420 $mime .= 'Content-Type: '.$media['mime'].';'.MAILHEADER_EOL; 421 $mime .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 422 $mime .= "Content-ID: <$cid>".MAILHEADER_EOL; 423 if($media['embed']) { 424 $mime .= 'Content-Disposition: inline; filename="'.$media['name'].'"'.MAILHEADER_EOL; 425 } else { 426 $mime .= 'Content-Disposition: attachment; filename="'.$media['name'].'"'.MAILHEADER_EOL; 427 } 428 $mime .= MAILHEADER_EOL; //end of headers 429 $mime .= chunk_split(base64_encode($media['data']), 74, MAILHEADER_EOL); 430 431 $part++; 432 } 433 return $mime; 434 } 435 436 /** 437 * Build the body and handles multi part mails 438 * 439 * Needs to be called before prepareHeaders! 440 * 441 * @return string the prepared mail body, false on errors 442 */ 443 protected function prepareBody() { 444 445 // no HTML mails allowed? remove HTML body 446 if(!$this->allowhtml) { 447 $this->html = ''; 448 } 449 450 // check for body 451 if(!$this->text && !$this->html) { 452 return false; 453 } 454 455 // add general headers 456 $this->headers['MIME-Version'] = '1.0'; 457 458 $body = ''; 459 460 if(!$this->html && !count($this->attach)) { // we can send a simple single part message 461 $this->headers['Content-Type'] = 'text/plain; charset=UTF-8'; 462 $this->headers['Content-Transfer-Encoding'] = 'base64'; 463 $body .= chunk_split(base64_encode($this->text), 74, MAILHEADER_EOL); 464 } else { // multi part it is 465 $body .= "This is a multi-part message in MIME format.".MAILHEADER_EOL; 466 467 // prepare the attachments 468 $attachments = $this->prepareAttachments(); 469 470 // do we have alternative text content? 471 if($this->text && $this->html) { 472 $this->headers['Content-Type'] = 'multipart/alternative;'.MAILHEADER_EOL. 473 ' boundary="'.$this->boundary.'XX"'; 474 $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL; 475 $body .= 'Content-Type: text/plain; charset=UTF-8'.MAILHEADER_EOL; 476 $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 477 $body .= MAILHEADER_EOL; 478 $body .= chunk_split(base64_encode($this->text), 74, MAILHEADER_EOL); 479 $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL; 480 $body .= 'Content-Type: multipart/related;'.MAILHEADER_EOL. 481 ' boundary="'.$this->boundary.'"'.MAILHEADER_EOL; 482 $body .= MAILHEADER_EOL; 483 } 484 485 $body .= '--'.$this->boundary.MAILHEADER_EOL; 486 $body .= 'Content-Type: text/html; charset=UTF-8'.MAILHEADER_EOL; 487 $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 488 $body .= MAILHEADER_EOL; 489 $body .= chunk_split(base64_encode($this->html), 74, MAILHEADER_EOL); 490 $body .= MAILHEADER_EOL; 491 $body .= $attachments; 492 $body .= '--'.$this->boundary.'--'.MAILHEADER_EOL; 493 494 // close open multipart/alternative boundary 495 if($this->text && $this->html) { 496 $body .= '--'.$this->boundary.'XX--'.MAILHEADER_EOL; 497 } 498 } 499 500 return $body; 501 } 502 503 /** 504 * Cleanup and encode the headers array 505 */ 506 protected function cleanHeaders() { 507 global $conf; 508 509 // clean up addresses 510 if(empty($this->headers['From'])) $this->from($conf['mailfrom']); 511 $addrs = array('To', 'From', 'Cc', 'Bcc'); 512 foreach($addrs as $addr) { 513 if(isset($this->headers[$addr])) { 514 $this->headers[$addr] = $this->cleanAddress($this->headers[$addr]); 515 } 516 } 517 518 if(isset($this->headers['Subject'])) { 519 // add prefix to subject 520 if(empty($conf['mailprefix'])) { 521 if(utf8_strlen($conf['title']) < 20) { 522 $prefix = '['.$conf['title'].']'; 523 } else { 524 $prefix = '['.utf8_substr($conf['title'], 0, 20).'...]'; 525 } 526 } else { 527 $prefix = '['.$conf['mailprefix'].']'; 528 } 529 $len = strlen($prefix); 530 if(substr($this->headers['Subject'], 0, $len) != $prefix) { 531 $this->headers['Subject'] = $prefix.' '.$this->headers['Subject']; 532 } 533 534 // encode subject 535 if(defined('MAILHEADER_ASCIIONLY')) { 536 $this->headers['Subject'] = utf8_deaccent($this->headers['Subject']); 537 $this->headers['Subject'] = utf8_strip($this->headers['Subject']); 538 } 539 if(!utf8_isASCII($this->headers['Subject'])) { 540 $this->headers['Subject'] = '=?UTF-8?B?'.base64_encode($this->headers['Subject']).'?='; 541 } 542 } 543 544 // wrap headers 545 foreach($this->headers as $key => $val) { 546 $this->headers[$key] = wordwrap($val, 78, MAILHEADER_EOL.' '); 547 } 548 } 549 550 /** 551 * Create a string from the headers array 552 * 553 * @returns string the headers 554 */ 555 protected function prepareHeaders() { 556 $headers = ''; 557 foreach($this->headers as $key => $val) { 558 $headers .= "$key: $val".MAILHEADER_EOL; 559 } 560 return $headers; 561 } 562 563 /** 564 * return a full email with all headers 565 * 566 * This is mainly intended for debugging and testing but could also be 567 * used for MHT exports 568 * 569 * @return string the mail, false on errors 570 */ 571 public function dump() { 572 $this->cleanHeaders(); 573 $body = $this->prepareBody(); 574 if($body === false) return false; 575 $headers = $this->prepareHeaders(); 576 577 return $headers.MAILHEADER_EOL.$body; 578 } 579 580 /** 581 * Send the mail 582 * 583 * Call this after all data was set 584 * 585 * @triggers MAIL_MESSAGE_SEND 586 * @return bool true if the mail was successfully passed to the MTA 587 */ 588 public function send() { 589 $success = false; 590 591 // prepare hook data 592 $data = array( 593 // pass the whole mail class to plugin 594 'mail' => $this, 595 // pass references for backward compatibility 596 'to' => &$this->headers['To'], 597 'cc' => &$this->headers['Cc'], 598 'bcc' => &$this->headers['Bcc'], 599 'from' => &$this->headers['From'], 600 'subject' => &$this->headers['Subject'], 601 'body' => &$this->text, 602 'params' => &$this->sendparam, 603 'headers' => '', // plugins shouldn't use this 604 // signal if we mailed successfully to AFTER event 605 'success' => &$success, 606 ); 607 608 // do our thing if BEFORE hook approves 609 $evt = new Doku_Event('MAIL_MESSAGE_SEND', $data); 610 if($evt->advise_before(true)) { 611 // clean up before using the headers 612 $this->cleanHeaders(); 613 614 // any recipients? 615 if(trim($this->headers['To']) === '' && 616 trim($this->headers['Cc']) === '' && 617 trim($this->headers['Bcc']) === '' 618 ) return false; 619 620 // The To: header is special 621 if(isset($this->headers['To'])) { 622 $to = $this->headers['To']; 623 unset($this->headers['To']); 624 } else { 625 $to = ''; 626 } 627 628 // so is the subject 629 if(isset($this->headers['Subject'])) { 630 $subject = $this->headers['Subject']; 631 unset($this->headers['Subject']); 632 } else { 633 $subject = ''; 634 } 635 636 // make the body 637 $body = $this->prepareBody(); 638 if($body === false) return false; 639 640 // cook the headers 641 $headers = $this->prepareHeaders(); 642 // add any headers set by legacy plugins 643 if(trim($data['headers'])) { 644 $headers .= MAILHEADER_EOL.trim($data['headers']); 645 } 646 647 // send the thing 648 if(is_null($this->sendparam)) { 649 $success = @mail($to, $subject, $body, $headers); 650 } else { 651 $success = @mail($to, $subject, $body, $headers, $this->sendparam); 652 } 653 } 654 // any AFTER actions? 655 $evt->advise_after(); 656 return $success; 657 } 658} 659