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