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