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