xref: /dokuwiki/inc/Mailer.class.php (revision 1d045709e66a239d6a0933c0d07dfbb9fb257d48)
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     * @param string $header the header name (no trailing colon!)
93     * @param string $value  the value of the header
94     * @param bool   $clean  remove all non-ASCII chars and line feeds?
95     */
96    public function setHeader($header,$value,$clean=true){
97        $header = ucwords(strtolower($header)); // streamline casing
98        if($clean){
99            $header = preg_replace('/[^\w \-\.\+\@]+/','',$header);
100            $value  = preg_replace('/[^\w \-\.\+\@]+/','',$value);
101        }
102        $this->headers[$header] = $value;
103    }
104
105    /**
106     * Set additional parameters to be passed to sendmail
107     *
108     * Whatever is set here is directly passed to PHP's mail() command as last
109     * parameter. Depending on the PHP setup this might break mailing alltogether
110     */
111    public function setParameters($param){
112        $this->sendparam = $param;
113    }
114
115    /**
116     * Set the HTML part of the mail
117     *
118     * Placeholders can be used to reference embedded attachments
119     */
120    public function setHTML($html){
121        $this->html = $html;
122    }
123
124    /**
125     * Set the plain text part of the mail
126     */
127    public function setText($text){
128        $this->text = $text;
129    }
130
131    /**
132     * Sets an email address header with correct encoding
133     *
134     * Unicode characters will be deaccented and encoded base64
135     * for headers. Addresses may not contain Non-ASCII data!
136     *
137     * Example:
138     *   setAddress("föö <foo@bar.com>, me@somewhere.com","TBcc");
139     *
140     * @param string  $address Multiple adresses separated by commas
141     * @param string  $header  Name of the header (To,Bcc,Cc,...)
142     */
143    function setAddress($address,$header){
144        // No named recipients for To: in Windows (see FS#652)
145        $names = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') ? false : true;
146
147        $header = ucwords(strtolower($header)); // streamline casing
148        $address = preg_replace('/[\r\n\0]+/',' ',$address); // remove attack vectors
149
150        $headers = '';
151        $parts = explode(',',$address);
152        foreach ($parts as $part){
153            $part = trim($part);
154
155            // parse address
156            if(preg_match('#(.*?)<(.*?)>#',$part,$matches)){
157                $text = trim($matches[1]);
158                $addr = $matches[2];
159            }else{
160                $addr = $part;
161            }
162            // skip empty ones
163            if(empty($addr)){
164                continue;
165            }
166
167            // FIXME: is there a way to encode the localpart of a emailaddress?
168            if(!utf8_isASCII($addr)){
169                msg(htmlspecialchars("E-Mail address <$addr> is not ASCII"),-1);
170                continue;
171            }
172
173            if(is_null($this->validator)){
174                $this->validator = new EmailAddressValidator();
175                $this->validator->allowLocalAddresses = true;
176            }
177            if(!$this->validator->check_email_address($addr)){
178                msg(htmlspecialchars("E-Mail address <$addr> is not valid"),-1);
179                continue;
180            }
181
182            // text was given
183            if(!empty($text) && $names){
184                // add address quotes
185                $addr = "<$addr>";
186
187                if(defined('MAILHEADER_ASCIIONLY')){
188                    $text = utf8_deaccent($text);
189                    $text = utf8_strip($text);
190                }
191
192                if(!utf8_isASCII($text)){
193                    //FIXME check if this is needed for base64 too
194                    // put the quotes outside as in =?UTF-8?Q?"Elan Ruusam=C3=A4e"?= vs "=?UTF-8?Q?Elan Ruusam=C3=A4e?="
195                    /*
196                    if (preg_match('/^"(.+)"$/', $text, $matches)) {
197                      $text = '"=?UTF-8?Q?'.mail_quotedprintable_encode($matches[1], 0).'?="';
198                    } else {
199                      $text = '=?UTF-8?Q?'.mail_quotedprintable_encode($text, 0).'?=';
200                    }
201                    */
202                    $text = '=?UTF-8?B?'.base64_encode($text).'?=';
203                }
204            }else{
205                $text = '';
206            }
207
208            // add to header comma seperated
209            if($headers != ''){
210                $headers .= ',';
211                $headers .= MAILHEADER_EOL.' '; // avoid overlong mail headers
212            }
213            $headers .= $text.' '.$addr;
214        }
215
216        if(empty($headers)) return false;
217
218        $this->headers[$header] = $headers;
219        return $headers;
220    }
221
222    /**
223     * Add the To: recipients
224     *
225     * @see setAddress
226     * @param string  $address Multiple adresses separated by commas
227     */
228    public function to($address){
229        $this->setAddress($address, 'To');
230    }
231
232    /**
233     * Add the Cc: recipients
234     *
235     * @see setAddress
236     * @param string  $address Multiple adresses separated by commas
237     */
238    public function cc($address){
239        $this->setAddress($address, 'Cc');
240    }
241
242    /**
243     * Add the Bcc: recipients
244     *
245     * @see setAddress
246     * @param string  $address Multiple adresses separated by commas
247     */
248    public function bcc($address){
249        $this->setAddress($address, 'Bcc');
250    }
251
252    /**
253     * Add the From: address
254     *
255     * This is set to $conf['mailfrom'] when not specified so you shouldn't need
256     * to call this function
257     *
258     * @see setAddress
259     * @param string  $address from address
260     */
261    public function from($address){
262        $this->setAddress($address, 'From');
263    }
264
265    /**
266     * Add the mail's Subject: header
267     *
268     * @param string $subject the mail subject
269     */
270    public function subject($subject){
271        if(!utf8_isASCII($subject)){
272            $subject = '=?UTF-8?B?'.base64_encode($subject).'?=';
273        }
274        $this->headers['Subject'] = $subject;
275    }
276
277    /**
278     * Prepare the mime multiparts for all attachments
279     *
280     * Replaces placeholders in the HTML with the correct CIDs
281     */
282    protected function prepareAttachments(){
283        $mime = '';
284        $part = 1;
285        // embedded attachments
286        foreach($this->attach as $media){
287            // create content id
288            $cid = 'part'.$part.'.'.$this->partid;
289
290            // replace wildcards
291            if($media['embed']){
292                $this->html = str_replace('%%'.$media['embed'].'%%','cid:'.$cid,$this->html);
293            }
294
295            $mime .= '--'.$this->boundary.MAILHEADER_EOL;
296            $mime .= 'Content-Type: '.$media['mime'].';'.MAILHEADER_EOL;
297            $mime .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL;
298            $mime .= "Content-ID: <$cid>".MAILHEADER_EOL;
299            if($media['embed']){
300                $mime .= 'Content-Disposition: inline; filename="'.$media['name'].'"'.MAILHEADER_EOL;
301            }else{
302                $mime .= 'Content-Disposition: attachment; filename="'.$media['name'].'"'.MAILHEADER_EOL;
303            }
304            $mime .= MAILHEADER_EOL; //end of headers
305            $mime .= chunk_split(base64_encode($media['data']),74,MAILHEADER_EOL);
306
307            $part++;
308        }
309        return $mime;
310    }
311
312    /**
313     * Build the body and handles multi part mails
314     *
315     * Needs to be called before prepareHeaders!
316     *
317     * @return string the prepared mail body, false on errors
318     */
319    protected function prepareBody(){
320        global $conf;
321
322        // check for body
323        if(!$this->text && !$this->html){
324            return false;
325        }
326
327        // add general headers
328        if(!isset($this->headers['From'])) $this->from($conf['mailfrom']);
329        $this->headers['MIME-Version'] = '1.0';
330
331        $body = '';
332
333        if(!$this->html && !count($this->attach)){ // we can send a simple single part message
334            $this->headers['Content-Type'] = 'text/plain; charset=UTF-8';
335            $this->headers['Content-Transfer-Encoding'] = 'base64';
336            $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL);
337        }else{ // multi part it is
338            $body .= "This is a multi-part message in MIME format.".MAILHEADER_EOL;
339
340            // prepare the attachments
341            $attachments = $this->prepareAttachments();
342
343            // do we have alternative text content?
344            if($this->text && $this->html){
345                $this->headers['Content-Type'] = 'multipart/alternative; boundary="'.$this->boundary.'XX"';
346                $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL;
347                $body .= 'Content-Type: text/plain; charset=UTF-8'.MAILHEADER_EOL;
348                $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL;
349                $body .= MAILHEADER_EOL;
350                $body .= chunk_split(base64_encode($this->text),74,MAILHEADER_EOL);
351                $body .= '--'.$this->boundary.'XX'.MAILHEADER_EOL;
352                $body .= 'Content-Type: multipart/related; boundary="'.$this->boundary.'"'.MAILHEADER_EOL;
353                $body .= MAILHEADER_EOL;
354            }
355
356            $body .= '--'.$this->boundary.MAILHEADER_EOL;
357            $body .= 'Content-Type: text/html; charset=UTF-8'.MAILHEADER_EOL;
358            $body .= 'Content-Transfer-Encoding: base64'.MAILHEADER_EOL;
359            $body .= MAILHEADER_EOL;
360            $body .= chunk_split(base64_encode($this->html),74,MAILHEADER_EOL);
361            $body .= MAILHEADER_EOL;
362            $body .= $attachments;
363            $body .= '--'.$this->boundary.'--'.MAILHEADER_EOL;
364
365            // close open multipart/alternative boundary
366            if($this->text && $this->html){
367                $body .= '--'.$this->boundary.'XX--'.MAILHEADER_EOL;
368            }
369        }
370
371        return $body;
372    }
373
374    /**
375     * Create a string from the headers array
376     *
377     * @returns string the headers
378     */
379    protected function prepareHeaders(){
380        $headers = '';
381        foreach($this->headers as $key => $val){
382            $headers .= "$key: $val".MAILHEADER_EOL;
383        }
384        return $headers;
385    }
386
387    /**
388     * return a full email with all headers
389     *
390     * This is mainly intended for debugging and testing but could also be
391     * used for MHT exports
392     *
393     * @return string the mail, false on errors
394     */
395    public function dump(){
396        $body    = $this->prepareBody();
397        if($body === 'false') return false;
398        $headers = $this->prepareHeaders();
399
400        return $headers.MAILHEADER_EOL.$body;
401    }
402
403    /**
404     * Send the mail
405     *
406     * Call this after all data was set
407     *
408     * @fixme we need to support the old plugin hook here!
409     * @return bool true if the mail was successfully passed to the MTA
410     */
411    public function send(){
412        // any recipients?
413        if(trim($this->headers['To'])  === '' &&
414           trim($this->headers['Cc'])  === '' &&
415           trim($this->headers['Bcc']) === '') return false;
416
417        // The To: header is special
418        if(isset($this->headers['To'])){
419            $to = $this->headers['To'];
420            unset($this->headers['To']);
421        }else{
422            $to = '';
423        }
424
425        // so is the subject
426        if(isset($this->headers['Subject'])){
427            $subject = $this->headers['Subject'];
428            unset($this->headers['Subject']);
429        }else{
430            $subject = '';
431        }
432
433        // make the body
434        $body    = $this->prepareBody();
435        if($body === 'false') return false;
436
437        // cook the headers
438        $headers = $this->prepareHeaders();
439
440        // send the thing
441        if(is_null($this->sendparam)){
442            return @mail($to,$subject,$body,$headers);
443        }else{
444            return @mail($to,$subject,$body,$headers,$this->sendparam);
445        }
446    }
447}
448