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