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 = $text; 175 $html = hsc($html); 176 $html = preg_replace('/^-----*$/m','<hr >',$html); 177 $html = nl2br($html); 178 } 179 if($wrap){ 180 $wrap = rawLocale('mailwrap','html'); 181 $html = preg_replace('/\n-- <br \/>.*$/s','',$html); //strip signature 182 $html = str_replace('@HTMLBODY@',$html,$wrap); 183 } 184 185 // copy over all replacements missing for HTML (autolink URLs) 186 foreach($textrep as $key => $value){ 187 if(isset($htmlrep[$key])) continue; 188 if(preg_match('/^https?:\/\//i',$value)){ 189 $htmlrep[$key] = '<a href="'.hsc($value).'">'.hsc($value).'</a>'; 190 }else{ 191 $htmlrep[$key] = hsc($value); 192 } 193 } 194 195 // embed media from templates 196 $html = preg_replace_callback('/@MEDIA\(([^\)]+)\)@/', 197 array($this,'autoembed_cb'),$html); 198 199 // prepare default replacements 200 $ip = clientIP(); 201 $cip = gethostsbyaddrs($ip); 202 $trep = array( 203 'DATE' => dformat(), 204 'BROWSER' => $_SERVER['HTTP_USER_AGENT'], 205 'IPADDRESS' => $ip, 206 'HOSTNAME' => $cip, 207 'TITLE' => $conf['title'], 208 'DOKUWIKIURL' => DOKU_URL, 209 'USER' => $_SERVER['REMOTE_USER'], 210 'NAME' => $INFO['userinfo']['name'], 211 'MAIL' => $INFO['userinfo']['mail'], 212 ); 213 $trep = array_merge($trep,(array) $textrep); 214 $hrep = array( 215 'DATE' => '<i>'.hsc(dformat()).'</i>', 216 'BROWSER' => hsc($_SERVER['HTTP_USER_AGENT']), 217 'IPADDRESS' => '<code>'.hsc($ip).'</code>', 218 'HOSTNAME' => '<code>'.hsc($cip).'</code>', 219 'TITLE' => hsc($conf['title']), 220 'DOKUWIKIURL' => '<a href="'.DOKU_URL.'">'.DOKU_URL.'</a>', 221 'USER' => hsc($_SERVER['REMOTE_USER']), 222 'NAME' => hsc($INFO['userinfo']['name']), 223 'MAIL' => '<a href="mailto:"'.hsc($INFO['userinfo']['mail']).'">'. 224 hsc($INFO['userinfo']['mail']).'</a>', 225 ); 226 $hrep = array_merge($hrep,(array) $htmlrep); 227 228 // Apply replacements 229 foreach ($trep as $key => $substitution) { 230 $text = str_replace('@'.strtoupper($key).'@',$substitution, $text); 231 } 232 foreach ($hrep as $key => $substitution) { 233 $html = str_replace('@'.strtoupper($key).'@',$substitution, $html); 234 } 235 236 $this->setHTML($html); 237 $this->setText($text); 238 } 239 240 /** 241 * Set the HTML part of the mail 242 * 243 * Placeholders can be used to reference embedded attachments 244 * 245 * You probably want to use setBody() instead 246 */ 247 public function setHTML($html){ 248 $this->html = $html; 249 } 250 251 /** 252 * Set the plain text part of the mail 253 * 254 * You probably want to use setBody() instead 255 */ 256 public function setText($text){ 257 $this->text = $text; 258 } 259 260 /** 261 * Add the To: recipients 262 * 263 * @see setAddress 264 * @param string $address Multiple adresses separated by commas 265 */ 266 public function to($address){ 267 $this->setHeader('To', $address, false); 268 } 269 270 /** 271 * Add the Cc: recipients 272 * 273 * @see setAddress 274 * @param string $address Multiple adresses separated by commas 275 */ 276 public function cc($address){ 277 $this->setHeader('Cc', $address, false); 278 } 279 280 /** 281 * Add the Bcc: recipients 282 * 283 * @see setAddress 284 * @param string $address Multiple adresses separated by commas 285 */ 286 public function bcc($address){ 287 $this->setHeader('Bcc', $address, false); 288 } 289 290 /** 291 * Add the From: address 292 * 293 * This is set to $conf['mailfrom'] when not specified so you shouldn't need 294 * to call this function 295 * 296 * @see setAddress 297 * @param string $address from address 298 */ 299 public function from($address){ 300 $this->setHeader('From', $address, false); 301 } 302 303 /** 304 * Add the mail's Subject: header 305 * 306 * @param string $subject the mail subject 307 */ 308 public function subject($subject){ 309 $this->headers['Subject'] = $subject; 310 } 311 312 /** 313 * Sets an email address header with correct encoding 314 * 315 * Unicode characters will be deaccented and encoded base64 316 * for headers. Addresses may not contain Non-ASCII data! 317 * 318 * Example: 319 * setAddress("föö <foo@bar.com>, me@somewhere.com","TBcc"); 320 * 321 * @param string $address Multiple adresses separated by commas 322 * @param string returns the prepared header (can contain multiple lines) 323 */ 324 public function cleanAddress($address){ 325 // No named recipients for To: in Windows (see FS#652) 326 $names = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') ? false : true; 327 328 $address = preg_replace('/[\r\n\0]+/',' ',$address); // remove attack vectors 329 330 $headers = ''; 331 $parts = explode(',',$address); 332 foreach ($parts as $part){ 333 $part = trim($part); 334 335 // parse address 336 if(preg_match('#(.*?)<(.*?)>#',$part,$matches)){ 337 $text = trim($matches[1]); 338 $addr = $matches[2]; 339 }else{ 340 $addr = $part; 341 } 342 // skip empty ones 343 if(empty($addr)){ 344 continue; 345 } 346 347 // FIXME: is there a way to encode the localpart of a emailaddress? 348 if(!utf8_isASCII($addr)){ 349 msg(htmlspecialchars("E-Mail address <$addr> is not ASCII"),-1); 350 continue; 351 } 352 353 if(is_null($this->validator)){ 354 $this->validator = new EmailAddressValidator(); 355 $this->validator->allowLocalAddresses = true; 356 } 357 if(!$this->validator->check_email_address($addr)){ 358 msg(htmlspecialchars("E-Mail address <$addr> is not valid"),-1); 359 continue; 360 } 361 362 // text was given 363 if(!empty($text) && $names){ 364 // add address quotes 365 $addr = "<$addr>"; 366 367 if(defined('MAILHEADER_ASCIIONLY')){ 368 $text = utf8_deaccent($text); 369 $text = utf8_strip($text); 370 } 371 372 if(!utf8_isASCII($text)){ 373 $text = '=?UTF-8?B?'.base64_encode($text).'?='; 374 } 375 }else{ 376 $text = ''; 377 } 378 379 // add to header comma seperated 380 if($headers != ''){ 381 $headers .= ', '; 382 } 383 $headers .= $text.' '.$addr; 384 } 385 386 if(empty($headers)) return false; 387 388 return $headers; 389 } 390 391 392 /** 393 * Prepare the mime multiparts for all attachments 394 * 395 * Replaces placeholders in the HTML with the correct CIDs 396 */ 397 protected function prepareAttachments(){ 398 $mime = ''; 399 $part = 1; 400 // embedded attachments 401 foreach($this->attach as $media){ 402 // create content id 403 $cid = 'part'.$part.'.'.$this->partid; 404 405 // replace wildcards 406 if($media['embed']){ 407 $this->html = str_replace('%%'.$media['embed'].'%%','cid:'.$cid,$this->html); 408 } 409 410 $mime .= '--'.$this->boundary.MAILHEADER_EOL; 411 $mime .= 'Content-Type: '.$media['mime'].';'.MAILHEADER_EOL; 412 $mime .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 413 $mime .= "Content-ID: <$cid>".MAILHEADER_EOL; 414 if($media['embed']){ 415 $mime .= 'Content-Disposition: inline; filename="'.$media['name'].'"'.MAILHEADER_EOL; 416 }else{ 417 $mime .= 'Content-Disposition: attachment; filename="'.$media['name'].'"'.MAILHEADER_EOL; 418 } 419 $mime .= MAILHEADER_EOL; //end of headers 420 $mime .= chunk_split(base64_encode($media['data']),74,MAILHEADER_EOL); 421 422 $part++; 423 } 424 return $mime; 425 } 426 427 /** 428 * Build the body and handles multi part mails 429 * 430 * Needs to be called before prepareHeaders! 431 * 432 * @return string the prepared mail body, false on errors 433 */ 434 protected function prepareBody(){ 435 global $conf; 436 437 // check for body 438 if(!$this->text && !$this->html){ 439 return false; 440 } 441 442 // add general headers 443 $this->headers['MIME-Version'] = '1.0'; 444 445 $body = ''; 446 447 if(!$this->html && !count($this->attach)){ // we can send a simple single part message 448 $this->headers['Content-Type'] = 'text/plain; charset=UTF-8'; 449 $this->headers['Content-Transfer-Encoding'] = 'base64'; 450 $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL); 451 }else{ // multi part it is 452 $body .= "This is a multi-part message in MIME format.".MAILHEADER_EOL; 453 454 // prepare the attachments 455 $attachments = $this->prepareAttachments(); 456 457 // do we have alternative text content? 458 if($this->text && $this->html){ 459 $this->headers['Content-Type'] = 'multipart/alternative;'.MAILHEADER_EOL. 460 ' boundary="'.$this->boundary.'XX"'; 461 $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL; 462 $body .= 'Content-Type: text/plain; charset=UTF-8'.MAILHEADER_EOL; 463 $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 464 $body .= MAILHEADER_EOL; 465 $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL); 466 $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL; 467 $body .= 'Content-Type: multipart/related;'.MAILHEADER_EOL. 468 ' boundary="'.$this->boundary.'"'.MAILHEADER_EOL; 469 $body .= MAILHEADER_EOL; 470 } 471 472 $body .= '--'.$this->boundary.MAILHEADER_EOL; 473 $body .= 'Content-Type: text/html; charset=UTF-8'.MAILHEADER_EOL; 474 $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 475 $body .= MAILHEADER_EOL; 476 $body .= chunk_split(base64_encode($this->html),74,MAILHEADER_EOL); 477 $body .= MAILHEADER_EOL; 478 $body .= $attachments; 479 $body .= '--'.$this->boundary.'--'.MAILHEADER_EOL; 480 481 // close open multipart/alternative boundary 482 if($this->text && $this->html){ 483 $body .= '--'.$this->boundary.'XX--'.MAILHEADER_EOL; 484 } 485 } 486 487 return $body; 488 } 489 490 /** 491 * Cleanup and encode the headers array 492 */ 493 protected function cleanHeaders(){ 494 global $conf; 495 496 // clean up addresses 497 if(empty($this->headers['From'])) $this->from($conf['mailfrom']); 498 $addrs = array('To','From','Cc','Bcc'); 499 foreach($addrs as $addr){ 500 if(isset($this->headers[$addr])){ 501 $this->headers[$addr] = $this->cleanAddress($this->headers[$addr]); 502 } 503 } 504 505 if(isset($this->headers['Subject'])){ 506 // add prefix to subject 507 if(empty($conf['mailprefix'])){ 508 if(utf8_strlen($conf['title']) < 20) { 509 $prefix = '['.$conf['title'].']'; 510 }else{ 511 $prefix = '['.utf8_substr($conf['title'], 0, 20).'...]'; 512 } 513 }else{ 514 $prefix = '['.$conf['mailprefix'].']'; 515 } 516 $len = strlen($prefix); 517 if(substr($this->headers['Subject'],0,$len) != $prefix){ 518 $this->headers['Subject'] = $prefix.' '.$this->headers['Subject']; 519 } 520 521 // encode subject 522 if(defined('MAILHEADER_ASCIIONLY')){ 523 $this->headers['Subject'] = utf8_deaccent($this->headers['Subject']); 524 $this->headers['Subject'] = utf8_strip($this->headers['Subject']); 525 } 526 if(!utf8_isASCII($this->headers['Subject'])){ 527 $this->headers['Subject'] = '=?UTF-8?B?'.base64_encode($this->headers['Subject']).'?='; 528 } 529 } 530 531 // wrap headers 532 foreach($this->headers as $key => $val){ 533 $this->headers[$key] = wordwrap($val,78,MAILHEADER_EOL.' '); 534 } 535 } 536 537 /** 538 * Create a string from the headers array 539 * 540 * @returns string the headers 541 */ 542 protected function prepareHeaders(){ 543 $headers = ''; 544 foreach($this->headers as $key => $val){ 545 $headers .= "$key: $val".MAILHEADER_EOL; 546 } 547 return $headers; 548 } 549 550 /** 551 * return a full email with all headers 552 * 553 * This is mainly intended for debugging and testing but could also be 554 * used for MHT exports 555 * 556 * @return string the mail, false on errors 557 */ 558 public function dump(){ 559 $this->cleanHeaders(); 560 $body = $this->prepareBody(); 561 if($body === 'false') return false; 562 $headers = $this->prepareHeaders(); 563 564 return $headers.MAILHEADER_EOL.$body; 565 } 566 567 /** 568 * Send the mail 569 * 570 * Call this after all data was set 571 * 572 * @triggers MAIL_MESSAGE_SEND 573 * @return bool true if the mail was successfully passed to the MTA 574 */ 575 public function send(){ 576 $success = false; 577 578 // prepare hook data 579 $data = array( 580 // pass the whole mail class to plugin 581 'mail' => $this, 582 // pass references for backward compatibility 583 'to' => &$this->headers['To'], 584 'cc' => &$this->headers['Cc'], 585 'bcc' => &$this->headers['Bcc'], 586 'from' => &$this->headers['From'], 587 'subject' => &$this->headers['Subject'], 588 'body' => &$this->text, 589 'params' => &$this->sendparams, 590 'headers' => '', // plugins shouldn't use this 591 // signal if we mailed successfully to AFTER event 592 'success' => &$success, 593 ); 594 595 // do our thing if BEFORE hook approves 596 $evt = new Doku_Event('MAIL_MESSAGE_SEND', $data); 597 if ($evt->advise_before(true)) { 598 // clean up before using the headers 599 $this->cleanHeaders(); 600 601 // any recipients? 602 if(trim($this->headers['To']) === '' && 603 trim($this->headers['Cc']) === '' && 604 trim($this->headers['Bcc']) === '') return false; 605 606 // The To: header is special 607 if(isset($this->headers['To'])){ 608 $to = $this->headers['To']; 609 unset($this->headers['To']); 610 }else{ 611 $to = ''; 612 } 613 614 // so is the subject 615 if(isset($this->headers['Subject'])){ 616 $subject = $this->headers['Subject']; 617 unset($this->headers['Subject']); 618 }else{ 619 $subject = ''; 620 } 621 622 // make the body 623 $body = $this->prepareBody(); 624 if($body === 'false') return false; 625 626 // cook the headers 627 $headers = $this->prepareHeaders(); 628 // add any headers set by legacy plugins 629 if(trim($data['headers'])){ 630 $headers .= MAILHEADER_EOL.trim($data['headers']); 631 } 632 633 // send the thing 634 if(is_null($this->sendparam)){ 635 $success = @mail($to,$subject,$body,$headers); 636 }else{ 637 $success = @mail($to,$subject,$body,$headers,$this->sendparam); 638 } 639 } 640 // any AFTER actions? 641 $evt->advise_after(); 642 return $success; 643 } 644} 645