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