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 * @param string $header the header name (no trailing colon!) 93 * @param string $value the value of the header 94 * @param bool $clean remove all non-ASCII chars and line feeds? 95 */ 96 public function setHeader($header,$value,$clean=true){ 97 $header = ucwords(strtolower($header)); // streamline casing 98 if($clean){ 99 $header = preg_replace('/[^\w \-\.\+\@]+/','',$header); 100 $value = preg_replace('/[^\w \-\.\+\@]+/','',$value); 101 } 102 $this->headers[$header] = $value; 103 } 104 105 /** 106 * Set additional parameters to be passed to sendmail 107 * 108 * Whatever is set here is directly passed to PHP's mail() command as last 109 * parameter. Depending on the PHP setup this might break mailing alltogether 110 */ 111 public function setParameters($param){ 112 $this->sendparam = $param; 113 } 114 115 /** 116 * Set the HTML part of the mail 117 * 118 * Placeholders can be used to reference embedded attachments 119 */ 120 public function setHTML($html){ 121 $this->html = $html; 122 } 123 124 /** 125 * Set the plain text part of the mail 126 */ 127 public function setText($text){ 128 $this->text = $text; 129 } 130 131 /** 132 * Sets an email address header with correct encoding 133 * 134 * Unicode characters will be deaccented and encoded base64 135 * for headers. Addresses may not contain Non-ASCII data! 136 * 137 * Example: 138 * setAddress("föö <foo@bar.com>, me@somewhere.com","TBcc"); 139 * 140 * @param string $address Multiple adresses separated by commas 141 * @param string $header Name of the header (To,Bcc,Cc,...) 142 */ 143 function setAddress($address,$header){ 144 // No named recipients for To: in Windows (see FS#652) 145 $names = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') ? false : true; 146 147 $header = ucwords(strtolower($header)); // streamline casing 148 $address = preg_replace('/[\r\n\0]+/',' ',$address); // remove attack vectors 149 150 $headers = ''; 151 $parts = explode(',',$address); 152 foreach ($parts as $part){ 153 $part = trim($part); 154 155 // parse address 156 if(preg_match('#(.*?)<(.*?)>#',$part,$matches)){ 157 $text = trim($matches[1]); 158 $addr = $matches[2]; 159 }else{ 160 $addr = $part; 161 } 162 // skip empty ones 163 if(empty($addr)){ 164 continue; 165 } 166 167 // FIXME: is there a way to encode the localpart of a emailaddress? 168 if(!utf8_isASCII($addr)){ 169 msg(htmlspecialchars("E-Mail address <$addr> is not ASCII"),-1); 170 continue; 171 } 172 173 if(is_null($this->validator)){ 174 $this->validator = new EmailAddressValidator(); 175 $this->validator->allowLocalAddresses = true; 176 } 177 if(!$this->validator->check_email_address($addr)){ 178 msg(htmlspecialchars("E-Mail address <$addr> is not valid"),-1); 179 continue; 180 } 181 182 // text was given 183 if(!empty($text) && $names){ 184 // add address quotes 185 $addr = "<$addr>"; 186 187 if(defined('MAILHEADER_ASCIIONLY')){ 188 $text = utf8_deaccent($text); 189 $text = utf8_strip($text); 190 } 191 192 if(!utf8_isASCII($text)){ 193 //FIXME check if this is needed for base64 too 194 // put the quotes outside as in =?UTF-8?Q?"Elan Ruusam=C3=A4e"?= vs "=?UTF-8?Q?Elan Ruusam=C3=A4e?=" 195 /* 196 if (preg_match('/^"(.+)"$/', $text, $matches)) { 197 $text = '"=?UTF-8?Q?'.mail_quotedprintable_encode($matches[1], 0).'?="'; 198 } else { 199 $text = '=?UTF-8?Q?'.mail_quotedprintable_encode($text, 0).'?='; 200 } 201 */ 202 $text = '=?UTF-8?B?'.base64_encode($text).'?='; 203 } 204 }else{ 205 $text = ''; 206 } 207 208 // add to header comma seperated 209 if($headers != ''){ 210 $headers .= ','; 211 $headers .= MAILHEADER_EOL.' '; // avoid overlong mail headers 212 } 213 $headers .= $text.' '.$addr; 214 } 215 216 if(empty($headers)) return false; 217 218 $this->headers[$header] = $headers; 219 return $headers; 220 } 221 222 /** 223 * Add the To: recipients 224 * 225 * @see setAddress 226 * @param string $address Multiple adresses separated by commas 227 */ 228 public function to($address){ 229 $this->setAddress($address, 'To'); 230 } 231 232 /** 233 * Add the Cc: recipients 234 * 235 * @see setAddress 236 * @param string $address Multiple adresses separated by commas 237 */ 238 public function cc($address){ 239 $this->setAddress($address, 'Cc'); 240 } 241 242 /** 243 * Add the Bcc: recipients 244 * 245 * @see setAddress 246 * @param string $address Multiple adresses separated by commas 247 */ 248 public function bcc($address){ 249 $this->setAddress($address, 'Bcc'); 250 } 251 252 /** 253 * Add the From: address 254 * 255 * This is set to $conf['mailfrom'] when not specified so you shouldn't need 256 * to call this function 257 * 258 * @see setAddress 259 * @param string $address from address 260 */ 261 public function from($address){ 262 $this->setAddress($address, 'From'); 263 } 264 265 /** 266 * Add the mail's Subject: header 267 * 268 * @param string $subject the mail subject 269 */ 270 public function subject($subject){ 271 if(!utf8_isASCII($subject)){ 272 $subject = '=?UTF-8?B?'.base64_encode($subject).'?='; 273 } 274 $this->headers['Subject'] = $subject; 275 } 276 277 /** 278 * Prepare the mime multiparts for all attachments 279 * 280 * Replaces placeholders in the HTML with the correct CIDs 281 */ 282 protected function prepareAttachments(){ 283 $mime = ''; 284 $part = 1; 285 // embedded attachments 286 foreach($this->attach as $media){ 287 // create content id 288 $cid = 'part'.$part.'.'.$this->partid; 289 290 // replace wildcards 291 if($media['embed']){ 292 $this->html = str_replace('%%'.$media['embed'].'%%','cid:'.$cid,$this->html); 293 } 294 295 $mime .= '--'.$this->boundary.MAILHEADER_EOL; 296 $mime .= 'Content-Type: '.$media['mime'].';'.MAILHEADER_EOL; 297 $mime .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 298 $mime .= "Content-ID: <$cid>".MAILHEADER_EOL; 299 if($media['embed']){ 300 $mime .= 'Content-Disposition: inline; filename="'.$media['name'].'"'.MAILHEADER_EOL; 301 }else{ 302 $mime .= 'Content-Disposition: attachment; filename="'.$media['name'].'"'.MAILHEADER_EOL; 303 } 304 $mime .= MAILHEADER_EOL; //end of headers 305 $mime .= chunk_split(base64_encode($media['data']),74,MAILHEADER_EOL); 306 307 $part++; 308 } 309 return $mime; 310 } 311 312 /** 313 * Build the body and handles multi part mails 314 * 315 * Needs to be called before prepareHeaders! 316 * 317 * @return string the prepared mail body, false on errors 318 */ 319 protected function prepareBody(){ 320 global $conf; 321 322 // check for body 323 if(!$this->text && !$this->html){ 324 return false; 325 } 326 327 // add general headers 328 if(!isset($this->headers['From'])) $this->from($conf['mailfrom']); 329 $this->headers['MIME-Version'] = '1.0'; 330 331 $body = ''; 332 333 if(!$this->html && !count($this->attach)){ // we can send a simple single part message 334 $this->headers['Content-Type'] = 'text/plain; charset=UTF-8'; 335 $this->headers['Content-Transfer-Encoding'] = 'base64'; 336 $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL); 337 }else{ // multi part it is 338 $body .= "This is a multi-part message in MIME format.".MAILHEADER_EOL; 339 340 // prepare the attachments 341 $attachments = $this->prepareAttachments(); 342 343 // do we have alternative text content? 344 if($this->text && $this->html){ 345 $this->headers['Content-Type'] = 'multipart/alternative; boundary="'.$this->boundary.'XX"'; 346 $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL; 347 $body .= 'Content-Type: text/plain; charset=UTF-8'.MAILHEADER_EOL; 348 $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 349 $body .= MAILHEADER_EOL; 350 $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL); 351 $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL; 352 $body .= 'Content-Type: multipart/related; boundary="'.$this->boundary.'"'.MAILHEADER_EOL; 353 $body .= MAILHEADER_EOL; 354 } 355 356 $body .= '--'.$this->boundary.MAILHEADER_EOL; 357 $body .= 'Content-Type: text/html; charset=UTF-8'.MAILHEADER_EOL; 358 $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL; 359 $body .= MAILHEADER_EOL; 360 $body .= chunk_split(base64_encode($this->html),74,MAILHEADER_EOL); 361 $body .= MAILHEADER_EOL; 362 $body .= $attachments; 363 $body .= '--'.$this->boundary.'--'.MAILHEADER_EOL; 364 365 // close open multipart/alternative boundary 366 if($this->text && $this->html){ 367 $body .= '--'.$this->boundary.'XX--'.MAILHEADER_EOL; 368 } 369 } 370 371 return $body; 372 } 373 374 /** 375 * Create a string from the headers array 376 * 377 * @returns string the headers 378 */ 379 protected function prepareHeaders(){ 380 $headers = ''; 381 foreach($this->headers as $key => $val){ 382 $headers .= "$key: $val".MAILHEADER_EOL; 383 } 384 return $headers; 385 } 386 387 /** 388 * return a full email with all headers 389 * 390 * This is mainly intended for debugging and testing but could also be 391 * used for MHT exports 392 * 393 * @return string the mail, false on errors 394 */ 395 public function dump(){ 396 $body = $this->prepareBody(); 397 if($body === 'false') return false; 398 $headers = $this->prepareHeaders(); 399 400 return $headers.MAILHEADER_EOL.$body; 401 } 402 403 /** 404 * Send the mail 405 * 406 * Call this after all data was set 407 * 408 * @fixme we need to support the old plugin hook here! 409 * @return bool true if the mail was successfully passed to the MTA 410 */ 411 public function send(){ 412 // any recipients? 413 if(trim($this->headers['To']) === '' && 414 trim($this->headers['Cc']) === '' && 415 trim($this->headers['Bcc']) === '') return false; 416 417 // The To: header is special 418 if(isset($this->headers['To'])){ 419 $to = $this->headers['To']; 420 unset($this->headers['To']); 421 }else{ 422 $to = ''; 423 } 424 425 // so is the subject 426 if(isset($this->headers['Subject'])){ 427 $subject = $this->headers['Subject']; 428 unset($this->headers['Subject']); 429 }else{ 430 $subject = ''; 431 } 432 433 // make the body 434 $body = $this->prepareBody(); 435 if($body === 'false') return false; 436 437 // cook the headers 438 $headers = $this->prepareHeaders(); 439 440 // send the thing 441 if(is_null($this->sendparam)){ 442 return @mail($to,$subject,$body,$headers); 443 }else{ 444 return @mail($to,$subject,$body,$headers,$this->sendparam); 445 } 446 } 447} 448