1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\Mail\Storage;
11
12use Zend\Mail;
13use Zend\Stdlib\ErrorHandler;
14
15class Maildir extends AbstractStorage
16{
17    /**
18     * used message class, change it in an extended class to extend the returned message class
19     * @var string
20     */
21    protected $messageClass = '\Zend\Mail\Storage\Message\File';
22
23    /**
24     * data of found message files in maildir dir
25     * @var array
26     */
27    protected $files = array();
28
29    /**
30     * known flag chars in filenames
31     *
32     * This list has to be in alphabetical order for setFlags()
33     *
34     * @var array
35     */
36    protected static $knownFlags = array('D' => Mail\Storage::FLAG_DRAFT,
37                                          'F' => Mail\Storage::FLAG_FLAGGED,
38                                          'P' => Mail\Storage::FLAG_PASSED,
39                                          'R' => Mail\Storage::FLAG_ANSWERED,
40                                          'S' => Mail\Storage::FLAG_SEEN,
41                                          'T' => Mail\Storage::FLAG_DELETED);
42
43    // TODO: getFlags($id) for fast access if headers are not needed (i.e. just setting flags)?
44
45    /**
46     * Count messages all messages in current box
47     *
48     * @param mixed $flags
49     * @return int number of messages
50     */
51    public function countMessages($flags = null)
52    {
53        if ($flags === null) {
54            return count($this->files);
55        }
56
57        $count = 0;
58        if (!is_array($flags)) {
59            foreach ($this->files as $file) {
60                if (isset($file['flaglookup'][$flags])) {
61                    ++$count;
62                }
63            }
64            return $count;
65        }
66
67        $flags = array_flip($flags);
68        foreach ($this->files as $file) {
69            foreach ($flags as $flag => $v) {
70                if (!isset($file['flaglookup'][$flag])) {
71                    continue 2;
72                }
73            }
74            ++$count;
75        }
76        return $count;
77    }
78
79    /**
80     * Get one or all fields from file structure. Also checks if message is valid
81     *
82     * @param  int         $id    message number
83     * @param  string|null $field wanted field
84     * @throws Exception\InvalidArgumentException
85     * @return string|array wanted field or all fields as array
86     */
87    protected function _getFileData($id, $field = null)
88    {
89        if (!isset($this->files[$id - 1])) {
90            throw new Exception\InvalidArgumentException('id does not exist');
91        }
92
93        if (!$field) {
94            return $this->files[$id - 1];
95        }
96
97        if (!isset($this->files[$id - 1][$field])) {
98            throw new Exception\InvalidArgumentException('field does not exist');
99        }
100
101        return $this->files[$id - 1][$field];
102    }
103
104    /**
105     * Get a list of messages with number and size
106     *
107     * @param  int|null $id number of message or null for all messages
108     * @return int|array size of given message of list with all messages as array(num => size)
109     */
110    public function getSize($id = null)
111    {
112        if ($id !== null) {
113            $filedata = $this->_getFileData($id);
114            return isset($filedata['size']) ? $filedata['size'] : filesize($filedata['filename']);
115        }
116
117        $result = array();
118        foreach ($this->files as $num => $data) {
119            $result[$num + 1] = isset($data['size']) ? $data['size'] : filesize($data['filename']);
120        }
121
122        return $result;
123    }
124
125    /**
126     * Fetch a message
127     *
128     * @param  int $id number of message
129     * @return \Zend\Mail\Storage\Message\File
130     * @throws \Zend\Mail\Storage\Exception\ExceptionInterface
131     */
132    public function getMessage($id)
133    {
134        // TODO that's ugly, would be better to let the message class decide
135        if (strtolower($this->messageClass) == '\zend\mail\storage\message\file'
136            || is_subclass_of($this->messageClass, '\Zend\Mail\Storage\Message\File')) {
137            return new $this->messageClass(array('file'  => $this->_getFileData($id, 'filename'),
138                                                  'flags' => $this->_getFileData($id, 'flags')));
139        }
140
141        return new $this->messageClass(array('handler' => $this, 'id' => $id, 'headers' => $this->getRawHeader($id),
142                                              'flags'   => $this->_getFileData($id, 'flags')));
143    }
144
145    /*
146     * Get raw header of message or part
147     *
148     * @param  int               $id       number of message
149     * @param  null|array|string $part     path to part or null for message header
150     * @param  int               $topLines include this many lines with header (after an empty line)
151     * @throws Exception\RuntimeException
152     * @return string raw header
153     */
154    public function getRawHeader($id, $part = null, $topLines = 0)
155    {
156        if ($part !== null) {
157            // TODO: implement
158            throw new Exception\RuntimeException('not implemented');
159        }
160
161        $fh = fopen($this->_getFileData($id, 'filename'), 'r');
162
163        $content = '';
164        while (!feof($fh)) {
165            $line = fgets($fh);
166            if (!trim($line)) {
167                break;
168            }
169            $content .= $line;
170        }
171
172        fclose($fh);
173        return $content;
174    }
175
176    /*
177     * Get raw content of message or part
178     *
179     * @param  int               $id   number of message
180     * @param  null|array|string $part path to part or null for message content
181     * @throws Exception\RuntimeException
182     * @return string raw content
183     */
184    public function getRawContent($id, $part = null)
185    {
186        if ($part !== null) {
187            // TODO: implement
188            throw new Exception\RuntimeException('not implemented');
189        }
190
191        $fh = fopen($this->_getFileData($id, 'filename'), 'r');
192
193        while (!feof($fh)) {
194            $line = fgets($fh);
195            if (!trim($line)) {
196                break;
197            }
198        }
199
200        $content = stream_get_contents($fh);
201        fclose($fh);
202        return $content;
203    }
204
205    /**
206     * Create instance with parameters
207     * Supported parameters are:
208     *   - dirname dirname of mbox file
209     *
210     * @param  $params array mail reader specific parameters
211     * @throws Exception\InvalidArgumentException
212     */
213    public function __construct($params)
214    {
215        if (is_array($params)) {
216            $params = (object) $params;
217        }
218
219        if (!isset($params->dirname) || !is_dir($params->dirname)) {
220            throw new Exception\InvalidArgumentException('no valid dirname given in params');
221        }
222
223        if (!$this->_isMaildir($params->dirname)) {
224            throw new Exception\InvalidArgumentException('invalid maildir given');
225        }
226
227        $this->has['top'] = true;
228        $this->has['flags'] = true;
229        $this->_openMaildir($params->dirname);
230    }
231
232    /**
233     * check if a given dir is a valid maildir
234     *
235     * @param string $dirname name of dir
236     * @return bool dir is valid maildir
237     */
238    protected function _isMaildir($dirname)
239    {
240        if (file_exists($dirname . '/new') && !is_dir($dirname . '/new')) {
241            return false;
242        }
243        if (file_exists($dirname . '/tmp') && !is_dir($dirname . '/tmp')) {
244            return false;
245        }
246        return is_dir($dirname . '/cur');
247    }
248
249    /**
250     * open given dir as current maildir
251     *
252     * @param string $dirname name of maildir
253     * @throws Exception\RuntimeException
254     */
255    protected function _openMaildir($dirname)
256    {
257        if ($this->files) {
258            $this->close();
259        }
260
261        ErrorHandler::start(E_WARNING);
262        $dh    = opendir($dirname . '/cur/');
263        $error = ErrorHandler::stop();
264        if (!$dh) {
265            throw new Exception\RuntimeException('cannot open maildir', 0, $error);
266        }
267        $this->_getMaildirFiles($dh, $dirname . '/cur/');
268        closedir($dh);
269
270        ErrorHandler::start(E_WARNING);
271        $dh    = opendir($dirname . '/new/');
272        $error = ErrorHandler::stop();
273        if ($dh) {
274            $this->_getMaildirFiles($dh, $dirname . '/new/', array(Mail\Storage::FLAG_RECENT));
275            closedir($dh);
276        } elseif (file_exists($dirname . '/new/')) {
277            throw new Exception\RuntimeException('cannot read recent mails in maildir', 0, $error);
278        }
279    }
280
281    /**
282     * find all files in opened dir handle and add to maildir files
283     *
284     * @param resource $dh            dir handle used for search
285     * @param string   $dirname       dirname of dir in $dh
286     * @param array    $defaultFlags default flags for given dir
287     */
288    protected function _getMaildirFiles($dh, $dirname, $defaultFlags = array())
289    {
290        while (($entry = readdir($dh)) !== false) {
291            if ($entry[0] == '.' || !is_file($dirname . $entry)) {
292                continue;
293            }
294
295            ErrorHandler::start(E_NOTICE);
296            list($uniq, $info) = explode(':', $entry, 2);
297            list(, $size) = explode(',', $uniq, 2);
298            ErrorHandler::stop();
299            if ($size && $size[0] == 'S' && $size[1] == '=') {
300                $size = substr($size, 2);
301            }
302            if (!ctype_digit($size)) {
303                $size = null;
304            }
305
306            ErrorHandler::start(E_NOTICE);
307            list($version, $flags) = explode(',', $info, 2);
308            ErrorHandler::stop();
309            if ($version != 2) {
310                $flags = '';
311            }
312
313            $namedFlags = $defaultFlags;
314            $length = strlen($flags);
315            for ($i = 0; $i < $length; ++$i) {
316                $flag = $flags[$i];
317                $namedFlags[$flag] = isset(static::$knownFlags[$flag]) ? static::$knownFlags[$flag] : $flag;
318            }
319
320            $data = array('uniq'       => $uniq,
321                          'flags'      => $namedFlags,
322                          'flaglookup' => array_flip($namedFlags),
323                          'filename'   => $dirname . $entry);
324            if ($size !== null) {
325                $data['size'] = (int) $size;
326            }
327            $this->files[] = $data;
328        }
329    }
330
331    /**
332     * Close resource for mail lib. If you need to control, when the resource
333     * is closed. Otherwise the destructor would call this.
334     *
335     */
336    public function close()
337    {
338        $this->files = array();
339    }
340
341    /**
342     * Waste some CPU cycles doing nothing.
343     *
344     * @return bool always return true
345     */
346    public function noop()
347    {
348        return true;
349    }
350
351    /**
352     * stub for not supported message deletion
353     *
354     * @param $id
355     * @throws Exception\RuntimeException
356     */
357    public function removeMessage($id)
358    {
359        throw new Exception\RuntimeException('maildir is (currently) read-only');
360    }
361
362    /**
363     * get unique id for one or all messages
364     *
365     * if storage does not support unique ids it's the same as the message number
366     *
367     * @param int|null $id message number
368     * @return array|string message number for given message or all messages as array
369     */
370    public function getUniqueId($id = null)
371    {
372        if ($id) {
373            return $this->_getFileData($id, 'uniq');
374        }
375
376        $ids = array();
377        foreach ($this->files as $num => $file) {
378            $ids[$num + 1] = $file['uniq'];
379        }
380        return $ids;
381    }
382
383    /**
384     * get a message number from a unique id
385     *
386     * I.e. if you have a webmailer that supports deleting messages you should use unique ids
387     * as parameter and use this method to translate it to message number right before calling removeMessage()
388     *
389     * @param string $id unique id
390     * @throws Exception\InvalidArgumentException
391     * @return int message number
392     */
393    public function getNumberByUniqueId($id)
394    {
395        foreach ($this->files as $num => $file) {
396            if ($file['uniq'] == $id) {
397                return $num + 1;
398            }
399        }
400
401        throw new Exception\InvalidArgumentException('unique id not found');
402    }
403}
404