xref: /dokuwiki/inc/Mailer.class.php (revision f08086ec453c4529ae7361f9540b430ee61238f0)
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