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