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