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