xref: /dokuwiki/inc/Mailer.class.php (revision 54f3075553a782ff5e32a5326f0e225f4437be29)
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 HTML part of the mail
126     *
127     * Placeholders can be used to reference embedded attachments
128     */
129    public function setHTML($html){
130        $this->html = $html;
131    }
132
133    /**
134     * Set the plain text part of the mail
135     */
136    public function setText($text){
137        $this->text = $text;
138    }
139
140    /**
141     * Add the To: recipients
142     *
143     * @see setAddress
144     * @param string  $address Multiple adresses separated by commas
145     */
146    public function to($address){
147        $this->setHeader('To', $address, false);
148    }
149
150    /**
151     * Add the Cc: recipients
152     *
153     * @see setAddress
154     * @param string  $address Multiple adresses separated by commas
155     */
156    public function cc($address){
157        $this->setHeader('Cc', $address, false);
158    }
159
160    /**
161     * Add the Bcc: recipients
162     *
163     * @see setAddress
164     * @param string  $address Multiple adresses separated by commas
165     */
166    public function bcc($address){
167        $this->setHeader('Bcc', $address, false);
168    }
169
170    /**
171     * Add the From: address
172     *
173     * This is set to $conf['mailfrom'] when not specified so you shouldn't need
174     * to call this function
175     *
176     * @see setAddress
177     * @param string  $address from address
178     */
179    public function from($address){
180        $this->setHeader('From', $address, false);
181    }
182
183    /**
184     * Add the mail's Subject: header
185     *
186     * @param string $subject the mail subject
187     */
188    public function subject($subject){
189        $this->headers['Subject'] = $subject;
190    }
191
192    /**
193     * Sets an email address header with correct encoding
194     *
195     * Unicode characters will be deaccented and encoded base64
196     * for headers. Addresses may not contain Non-ASCII data!
197     *
198     * Example:
199     *   setAddress("föö <foo@bar.com>, me@somewhere.com","TBcc");
200     *
201     * @param string  $address Multiple adresses separated by commas
202     * @param string  returns the prepared header (can contain multiple lines)
203     */
204    public function cleanAddress($address){
205        // No named recipients for To: in Windows (see FS#652)
206        $names = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') ? false : true;
207
208        $address = preg_replace('/[\r\n\0]+/',' ',$address); // remove attack vectors
209
210        $headers = '';
211        $parts = explode(',',$address);
212        foreach ($parts as $part){
213            $part = trim($part);
214
215            // parse address
216            if(preg_match('#(.*?)<(.*?)>#',$part,$matches)){
217                $text = trim($matches[1]);
218                $addr = $matches[2];
219            }else{
220                $addr = $part;
221            }
222            // skip empty ones
223            if(empty($addr)){
224                continue;
225            }
226
227            // FIXME: is there a way to encode the localpart of a emailaddress?
228            if(!utf8_isASCII($addr)){
229                msg(htmlspecialchars("E-Mail address <$addr> is not ASCII"),-1);
230                continue;
231            }
232
233            if(is_null($this->validator)){
234                $this->validator = new EmailAddressValidator();
235                $this->validator->allowLocalAddresses = true;
236            }
237            if(!$this->validator->check_email_address($addr)){
238                msg(htmlspecialchars("E-Mail address <$addr> is not valid"),-1);
239                continue;
240            }
241
242            // text was given
243            if(!empty($text) && $names){
244                // add address quotes
245                $addr = "<$addr>";
246
247                if(defined('MAILHEADER_ASCIIONLY')){
248                    $text = utf8_deaccent($text);
249                    $text = utf8_strip($text);
250                }
251
252                if(!utf8_isASCII($text)){
253                    //FIXME check if this is needed for base64 too
254                    // put the quotes outside as in =?UTF-8?Q?"Elan Ruusam=C3=A4e"?= vs "=?UTF-8?Q?Elan Ruusam=C3=A4e?="
255                    /*
256                    if (preg_match('/^"(.+)"$/', $text, $matches)) {
257                      $text = '"=?UTF-8?Q?'.mail_quotedprintable_encode($matches[1], 0).'?="';
258                    } else {
259                      $text = '=?UTF-8?Q?'.mail_quotedprintable_encode($text, 0).'?=';
260                    }
261                    */
262                    $text = '=?UTF-8?B?'.base64_encode($text).'?=';
263                }
264            }else{
265                $text = '';
266            }
267
268            // add to header comma seperated
269            if($headers != ''){
270                $headers .= ', ';
271            }
272            $headers .= $text.' '.$addr;
273        }
274
275        if(empty($headers)) return false;
276
277        return $headers;
278    }
279
280
281    /**
282     * Prepare the mime multiparts for all attachments
283     *
284     * Replaces placeholders in the HTML with the correct CIDs
285     */
286    protected function prepareAttachments(){
287        $mime = '';
288        $part = 1;
289        // embedded attachments
290        foreach($this->attach as $media){
291            // create content id
292            $cid = 'part'.$part.'.'.$this->partid;
293
294            // replace wildcards
295            if($media['embed']){
296                $this->html = str_replace('%%'.$media['embed'].'%%','cid:'.$cid,$this->html);
297            }
298
299            $mime .= '--'.$this->boundary.MAILHEADER_EOL;
300            $mime .= 'Content-Type: '.$media['mime'].';'.MAILHEADER_EOL;
301            $mime .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL;
302            $mime .= "Content-ID: <$cid>".MAILHEADER_EOL;
303            if($media['embed']){
304                $mime .= 'Content-Disposition: inline; filename="'.$media['name'].'"'.MAILHEADER_EOL;
305            }else{
306                $mime .= 'Content-Disposition: attachment; filename="'.$media['name'].'"'.MAILHEADER_EOL;
307            }
308            $mime .= MAILHEADER_EOL; //end of headers
309            $mime .= chunk_split(base64_encode($media['data']),74,MAILHEADER_EOL);
310
311            $part++;
312        }
313        return $mime;
314    }
315
316    /**
317     * Build the body and handles multi part mails
318     *
319     * Needs to be called before prepareHeaders!
320     *
321     * @return string the prepared mail body, false on errors
322     */
323    protected function prepareBody(){
324        global $conf;
325
326        // check for body
327        if(!$this->text && !$this->html){
328            return false;
329        }
330
331        // add general headers
332        $this->headers['MIME-Version'] = '1.0';
333
334        $body = '';
335
336        if(!$this->html && !count($this->attach)){ // we can send a simple single part message
337            $this->headers['Content-Type'] = 'text/plain; charset=UTF-8';
338            $this->headers['Content-Transfer-Encoding'] = 'base64';
339            $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL);
340        }else{ // multi part it is
341            $body .= "This is a multi-part message in MIME format.".MAILHEADER_EOL;
342
343            // prepare the attachments
344            $attachments = $this->prepareAttachments();
345
346            // do we have alternative text content?
347            if($this->text && $this->html){
348                $this->headers['Content-Type'] = 'multipart/alternative;'.MAILHEADER_EOL.
349                                                 '  boundary="'.$this->boundary.'XX"';
350                $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL;
351                $body .= 'Content-Type: text/plain; charset=UTF-8'.MAILHEADER_EOL;
352                $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL;
353                $body .= MAILHEADER_EOL;
354                $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL);
355                $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL;
356                $body .= 'Content-Type: multipart/related;'.MAILHEADER_EOL.
357                         '  boundary="'.$this->boundary.'"'.MAILHEADER_EOL;
358                $body .= MAILHEADER_EOL;
359            }
360
361            $body .= '--'.$this->boundary.MAILHEADER_EOL;
362            $body .= 'Content-Type: text/html; charset=UTF-8'.MAILHEADER_EOL;
363            $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL;
364            $body .= MAILHEADER_EOL;
365            $body .= chunk_split(base64_encode($this->html),74,MAILHEADER_EOL);
366            $body .= MAILHEADER_EOL;
367            $body .= $attachments;
368            $body .= '--'.$this->boundary.'--'.MAILHEADER_EOL;
369
370            // close open multipart/alternative boundary
371            if($this->text && $this->html){
372                $body .= '--'.$this->boundary.'XX--'.MAILHEADER_EOL;
373            }
374        }
375
376        return $body;
377    }
378
379    /**
380     * Cleanup and encode the headers array
381     */
382    protected function cleanHeaders(){
383        global $conf;
384
385        // clean up addresses
386        if(empty($this->headers['From'])) $this->from($conf['mailfrom']);
387        $addrs = array('To','From','Cc','Bcc');
388        foreach($addrs as $addr){
389            if(isset($this->headers[$addr])){
390                $this->headers[$addr] = $this->cleanAddress($this->headers[$addr]);
391            }
392        }
393
394        if(isset($subject)){
395            // add prefix to subject
396            if(empty($conf['mailprefix'])){
397                $prefix = '['.$conf['title'].']';
398            }else{
399                $prefix = '['.$conf['mailprefix'].']';
400            }
401            $len = strlen($prefix);
402            if(substr($this->headers['subject'],0,$len) != $prefix){
403                $this->headers['subject'] = $prefix.' '.$this->headers['subject'];
404            }
405
406            // encode subject
407            if(defined('MAILHEADER_ASCIIONLY')){
408                $this->headers['subject'] = utf8_deaccent($this->headers['subject']);
409                $this->headers['subject'] = utf8_strip($this->headers['subject']);
410            }
411            if(!utf8_isASCII($this->headers['Subject'])){
412                $subject = '=?UTF-8?B?'.base64_encode($this->headers['Subject']).'?=';
413            }
414        }
415
416        // wrap headers
417        foreach($this->headers as $key => $val){
418            $this->headers[$key] = wordwrap($val,78,MAILHEADER_EOL.'  ');
419        }
420    }
421
422    /**
423     * Create a string from the headers array
424     *
425     * @returns string the headers
426     */
427    protected function prepareHeaders(){
428        $headers = '';
429        foreach($this->headers as $key => $val){
430            $headers .= "$key: $val".MAILHEADER_EOL;
431        }
432        return $headers;
433    }
434
435    /**
436     * return a full email with all headers
437     *
438     * This is mainly intended for debugging and testing but could also be
439     * used for MHT exports
440     *
441     * @return string the mail, false on errors
442     */
443    public function dump(){
444        $this->cleanHeaders();
445        $body    = $this->prepareBody();
446        if($body === 'false') return false;
447        $headers = $this->prepareHeaders();
448
449        return $headers.MAILHEADER_EOL.$body;
450    }
451
452    /**
453     * Send the mail
454     *
455     * Call this after all data was set
456     *
457     * @triggers MAIL_MESSAGE_SEND
458     * @return bool true if the mail was successfully passed to the MTA
459     */
460    public function send(){
461        $success = false;
462
463        // prepare hook data
464        $data = array(
465            // pass the whole mail class to plugin
466            'mail' => $this,
467            // pass references for backward compatibility
468            'to'      => &$this->headers['To'],
469            'cc'      => &$this->headers['Cc'],
470            'bcc'     => &$this->headers['Bcc'],
471            'from'    => &$this->headers['From'],
472            'subject' => &$this->headers['Subject'],
473            'body'    => &$this->text,
474            'params'  => &$this->sendparams,
475            'headers' => '', // plugins shouldn't use this
476            // signal if we mailed successfully to AFTER event
477            'success' => &$success,
478        );
479
480        // do our thing if BEFORE hook approves
481        $evt = new Doku_Event('MAIL_MESSAGE_SEND', $data);
482        if ($evt->advise_before(true)) {
483            // clean up before using the headers
484            $this->cleanHeaders();
485
486            // any recipients?
487            if(trim($this->headers['To'])  === '' &&
488               trim($this->headers['Cc'])  === '' &&
489               trim($this->headers['Bcc']) === '') return false;
490
491            // The To: header is special
492            if(isset($this->headers['To'])){
493                $to = $this->headers['To'];
494                unset($this->headers['To']);
495            }else{
496                $to = '';
497            }
498
499            // so is the subject
500            if(isset($this->headers['Subject'])){
501                $subject = $this->headers['Subject'];
502                unset($this->headers['Subject']);
503            }else{
504                $subject = '';
505            }
506
507            // make the body
508            $body    = $this->prepareBody();
509            if($body === 'false') return false;
510
511            // cook the headers
512            $headers = $this->prepareHeaders();
513            // add any headers set by legacy plugins
514            if(trim($data['headers'])){
515                $headers .= MAILHEADER_EOL.trim($data['headers']);
516            }
517
518            // send the thing
519            if(is_null($this->sendparam)){
520                $success = @mail($to,$subject,$body,$headers);
521            }else{
522                $success = @mail($to,$subject,$body,$headers,$this->sendparam);
523            }
524        }
525        // any AFTER actions?
526        $evt->advise_after();
527        return $success;
528    }
529}
530