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