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 //FIXME check if this is needed for base64 too 374 // put the quotes outside as in =?UTF-8?Q?"Elan Ruusam=C3=A4e"?= vs "=?UTF-8?Q?Elan Ruusam=C3=A4e?=" 375 /* 376 if (preg_match('/^"(.+)"$/', $text, $matches)) { 377 $text = '"=?UTF-8?Q?'.mail_quotedprintable_encode($matches[1], 0).'?="'; 378 } else { 379 $text = '=?UTF-8?Q?'.mail_quotedprintable_encode($text, 0).'?='; 380 } 381 */ 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 global $conf; 445 446 // check for body 447 if(!$this->text && !$this->html){ 448 return false; 449 } 450 451 // add general headers 452 $this->headers['MIME-Version'] = '1.0'; 453 454 $body = ''; 455 456 if(!$this->html && !count($this->attach)){ // we can send a simple single part message 457 $this->headers['Content-Type'] = 'text/plain; charset=UTF-8'; 458 $this->headers['Content-Transfer-Encoding'] = 'base64'; 459 $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL); 460 }else{ // multi part it is 461 $body .= "This is a multi-part message in MIME format.".MAILHEADER_EOL; 462 463 // prepare the attachments 464 $attachments = $this->prepareAttachments(); 465 466 // do we have alternative text content? 467 if($this->text && $this->html){ 468 $this->headers['Content-Type'] = 'multipart/alternative;'.MAILHEADER_EOL. 469 ' boundary="'.$this->boundary.'XX"'; 470 $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL; 471 $body .= 'Content-Type: text/plain; charset=UTF-8'.MAILHEADER_EOL; 472 $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 473 $body .= MAILHEADER_EOL; 474 $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL); 475 $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL; 476 $body .= 'Content-Type: multipart/related;'.MAILHEADER_EOL. 477 ' boundary="'.$this->boundary.'"'.MAILHEADER_EOL; 478 $body .= MAILHEADER_EOL; 479 } 480 481 $body .= '--'.$this->boundary.MAILHEADER_EOL; 482 $body .= 'Content-Type: text/html; charset=UTF-8'.MAILHEADER_EOL; 483 $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 484 $body .= MAILHEADER_EOL; 485 $body .= chunk_split(base64_encode($this->html),74,MAILHEADER_EOL); 486 $body .= MAILHEADER_EOL; 487 $body .= $attachments; 488 $body .= '--'.$this->boundary.'--'.MAILHEADER_EOL; 489 490 // close open multipart/alternative boundary 491 if($this->text && $this->html){ 492 $body .= '--'.$this->boundary.'XX--'.MAILHEADER_EOL; 493 } 494 } 495 496 return $body; 497 } 498 499 /** 500 * Cleanup and encode the headers array 501 */ 502 protected function cleanHeaders(){ 503 global $conf; 504 505 // clean up addresses 506 if(empty($this->headers['From'])) $this->from($conf['mailfrom']); 507 $addrs = array('To','From','Cc','Bcc'); 508 foreach($addrs as $addr){ 509 if(isset($this->headers[$addr])){ 510 $this->headers[$addr] = $this->cleanAddress($this->headers[$addr]); 511 } 512 } 513 514 if(isset($this->headers['Subject'])){ 515 // add prefix to subject 516 if(empty($conf['mailprefix'])){ 517 if(utf8_strlen($conf['title']) < 20) { 518 $prefix = '['.$conf['title'].']'; 519 }else{ 520 $prefix = '['.utf8_substr($conf['title'], 0, 20).'...]'; 521 } 522 }else{ 523 $prefix = '['.$conf['mailprefix'].']'; 524 } 525 $len = strlen($prefix); 526 if(substr($this->headers['Subject'],0,$len) != $prefix){ 527 $this->headers['Subject'] = $prefix.' '.$this->headers['Subject']; 528 } 529 530 // encode subject 531 if(defined('MAILHEADER_ASCIIONLY')){ 532 $this->headers['Subject'] = utf8_deaccent($this->headers['Subject']); 533 $this->headers['Subject'] = utf8_strip($this->headers['Subject']); 534 } 535 if(!utf8_isASCII($this->headers['Subject'])){ 536 $this->headers['Subject'] = '=?UTF-8?B?'.base64_encode($this->headers['Subject']).'?='; 537 } 538 } 539 540 // wrap headers 541 foreach($this->headers as $key => $val){ 542 $this->headers[$key] = wordwrap($val,78,MAILHEADER_EOL.' '); 543 } 544 } 545 546 /** 547 * Create a string from the headers array 548 * 549 * @returns string the headers 550 */ 551 protected function prepareHeaders(){ 552 $headers = ''; 553 foreach($this->headers as $key => $val){ 554 $headers .= "$key: $val".MAILHEADER_EOL; 555 } 556 return $headers; 557 } 558 559 /** 560 * return a full email with all headers 561 * 562 * This is mainly intended for debugging and testing but could also be 563 * used for MHT exports 564 * 565 * @return string the mail, false on errors 566 */ 567 public function dump(){ 568 $this->cleanHeaders(); 569 $body = $this->prepareBody(); 570 if($body === 'false') return false; 571 $headers = $this->prepareHeaders(); 572 573 return $headers.MAILHEADER_EOL.$body; 574 } 575 576 /** 577 * Send the mail 578 * 579 * Call this after all data was set 580 * 581 * @triggers MAIL_MESSAGE_SEND 582 * @return bool true if the mail was successfully passed to the MTA 583 */ 584 public function send(){ 585 $success = false; 586 587 // prepare hook data 588 $data = array( 589 // pass the whole mail class to plugin 590 'mail' => $this, 591 // pass references for backward compatibility 592 'to' => &$this->headers['To'], 593 'cc' => &$this->headers['Cc'], 594 'bcc' => &$this->headers['Bcc'], 595 'from' => &$this->headers['From'], 596 'subject' => &$this->headers['Subject'], 597 'body' => &$this->text, 598 'params' => &$this->sendparams, 599 'headers' => '', // plugins shouldn't use this 600 // signal if we mailed successfully to AFTER event 601 'success' => &$success, 602 ); 603 604 // do our thing if BEFORE hook approves 605 $evt = new Doku_Event('MAIL_MESSAGE_SEND', $data); 606 if ($evt->advise_before(true)) { 607 // clean up before using the headers 608 $this->cleanHeaders(); 609 610 // any recipients? 611 if(trim($this->headers['To']) === '' && 612 trim($this->headers['Cc']) === '' && 613 trim($this->headers['Bcc']) === '') return false; 614 615 // The To: header is special 616 if(isset($this->headers['To'])){ 617 $to = $this->headers['To']; 618 unset($this->headers['To']); 619 }else{ 620 $to = ''; 621 } 622 623 // so is the subject 624 if(isset($this->headers['Subject'])){ 625 $subject = $this->headers['Subject']; 626 unset($this->headers['Subject']); 627 }else{ 628 $subject = ''; 629 } 630 631 // make the body 632 $body = $this->prepareBody(); 633 if($body === 'false') return false; 634 635 // cook the headers 636 $headers = $this->prepareHeaders(); 637 // add any headers set by legacy plugins 638 if(trim($data['headers'])){ 639 $headers .= MAILHEADER_EOL.trim($data['headers']); 640 } 641 642 // send the thing 643 if(is_null($this->sendparam)){ 644 $success = @mail($to,$subject,$body,$headers); 645 }else{ 646 $success = @mail($to,$subject,$body,$headers,$this->sendparam); 647 } 648 } 649 // any AFTER actions? 650 $evt->advise_after(); 651 return $success; 652 } 653} 654