• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / imap / 30961302745

04 Aug 2026 11:49PM UTC coverage: 89.552% (+53.2%) from 36.364%
30961302745

push

github

dg
foo

240 of 268 relevant lines covered (89.55%)

0.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.05
/src/Mailbox.php
1
<?php declare(strict_types=1);
2

3
namespace DG\Imap;
4

5

6
/**
7
 * Represents an IMAP mailbox and provides functionality to interact with it.
8
 */
9
final class Mailbox
10
{
11
        private ?Connection $connection = null;
12
        private string $host;
13
        private int $port;
14
        private bool $ssl;
15
        private string $folder;
16

17
        /** @var ?array<string, list<string>>  folder name in UTF-8 => its attributes */
18
        private ?array $folders = null;
19

20
        /** counts sessions, so a message cannot act on a UID from a previous one */
21
        private int $generation = 0;
22

23

24
        /**
25
         * The mailbox specification uses the traditional c-client syntax, e.g. '{imap.gmail.com:993/imap/ssl}'.
26
         */
27
        public function __construct(
1✔
28
                string $mailbox,
29
                private string $username,
30
                private string $password,
31
        ) {
32
                if (!preg_match('~^\{([^:/}]+)(?::(\d+))?((?:/[\w.-]+)*)\}(.*)$~D', $mailbox, $m)) {
1✔
33
                        throw new Exception("Invalid mailbox specification '$mailbox'");
1✔
34
                }
35

36
                if (str_contains($m[3], '/tls')) {
1✔
37
                        throw new Exception('STARTTLS is not supported, use implicit TLS via /ssl');
1✔
38
                }
39

40
                $this->host = $m[1];
1✔
41
                $this->ssl = str_contains($m[3], '/ssl');
1✔
42
                $this->port = $m[2] === '' ? ($this->ssl ? 993 : 143) : (int) $m[2]; // a port of 0 is falsy, but given
1✔
43
                $this->folder = $m[4] === '' ? 'INBOX' : $m[4];
1✔
44
        }
1✔
45

46

47
        /**
48
         * Establishes a connection to the server and selects the folder.
49
         * @throws Exception  Connection or authentication failed.
50
         */
51
        public function connect(): void
52
        {
53
                $connection = Connection::connect($this->host, $this->port, $this->ssl);
1✔
54
                $connection->command('LOGIN ' . Connection::quote($this->username) . ' ' . Connection::quote($this->password));
1✔
55
                $connection->command('SELECT ' . Connection::quote(self::encodeName($this->folder)));
×
56
                $this->close(); // only once the new session stands, so a failure does not cost the old one
×
57
                $this->connection = $connection;
×
58
                $this->generation++;
×
59
        }
60

61

62
        /**
63
         * Fetches all messages from the mailbox; bodies are downloaded lazily.
64
         * @return list<Message>
65
         * @throws Exception
66
         */
67
        public function getMessages(): array
68
        {
69
                $connection = $this->connectIfNeeded();
×
70
                $res = [];
×
71
                foreach ($connection->command('UID FETCH 1:* (UID BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])') as $line) {
×
72
                        $parsed = str_starts_with($line, '* ') ? Connection::parseLiteral($line) : null;
×
73
                        if ($parsed && preg_match('~\bUID (\d+)~', $parsed[1], $m)) {
×
74
                                $res[] = new Message($this, $this->generation, (int) $m[1], MimeParser::parse($parsed[0])[0]);
×
75
                        }
76
                }
77

78
                return $res;
×
79
        }
80

81

82
        /**
83
         * Returns all folders on the server as name => attributes, with names in UTF-8.
84
         * @return array<string, list<string>>
85
         */
86
        public function getFolders(): array
87
        {
88
                if ($this->folders === null) {
1✔
89
                        $quotedPattern = '~^\* LIST \(([^)]*)\) (?:"(?:[^"\\\]|\\\.)*"|NIL) (?:"((?:[^"\\\]|\\\.)*)"|(\S+))~';
1✔
90
                        $folders = [];
1✔
91
                        foreach ($this->connectIfNeeded()->command('LIST "" "*"') as $line) {
1✔
92
                                $parsed = Connection::parseLiteral($line); // a name with special characters travels as a literal
1✔
93
                                if ($parsed && preg_match('~^\* LIST \(([^)]*)\)~', $parsed[1], $m)) {
1✔
94
                                        $name = $parsed[0];
1✔
95
                                } elseif (!$parsed && preg_match($quotedPattern, $line, $m)) {
1✔
96
                                        $name = stripslashes(($m[2] ?? '') !== '' ? $m[2] : ($m[3] ?? ''));
1✔
97
                                } else {
98
                                        continue;
×
99
                                }
100

101
                                $folders[self::decodeName($name)] = preg_split('~\s+~', $m[1], -1, PREG_SPLIT_NO_EMPTY);
1✔
102
                        }
103

104
                        $this->folders = $folders; // cached only once the listing completed, so a failure can be retried
1✔
105
                }
106

107
                return $this->folders;
1✔
108
        }
109

110

111
        /**
112
         * Returns the folder the server designates for the given purpose (RFC 6154), e.g. '\Trash'
113
         * or '\Archive', or null when the server advertises none.
114
         */
115
        public function getSpecialFolder(string $attribute): ?string
1✔
116
        {
117
                $attribute = strtolower($attribute);
1✔
118
                foreach ($this->getFolders() as $name => $attributes) {
1✔
119
                        foreach ($attributes as $candidate) {
1✔
120
                                if (strtolower($candidate) === $attribute) {
1✔
121
                                        return $name;
1✔
122
                                }
123
                        }
124
                }
125

126
                return null;
1✔
127
        }
128

129

130
        /**
131
         * Closes the connection to the mailbox.
132
         */
133
        public function close(): void
134
        {
135
                if ($this->connection) {
1✔
136
                        try {
137
                                $this->connection->command('LOGOUT');
1✔
138
                        } catch (Exception) {
×
139
                                // closing must not fail
140
                        }
141
                        $this->connection->disconnect();
1✔
142
                        $this->connection = null;
1✔
143
                        $this->folders = null;
1✔
144
                }
145
        }
1✔
146

147

148
        /**
149
         * Permanently removes the message from the mailbox.
150
         * @internal
151
         */
152
        public function deleteMessage(int $uid): void
1✔
153
        {
154
                $connection = $this->getConnection();
1✔
155
                $connection->command("UID STORE $uid +FLAGS.SILENT (\\Deleted)");
1✔
156
                $this->expunge($uid);
1✔
157
        }
1✔
158

159

160
        /**
161
         * Moves the message to another folder, named in UTF-8.
162
         * @internal
163
         */
164
        public function moveMessage(int $uid, string $folder): void
1✔
165
        {
166
                $connection = $this->getConnection();
1✔
167
                $target = Connection::quote(self::encodeName($folder));
1✔
168
                if ($connection->hasCapability('MOVE')) {
1✔
169
                        $connection->command("UID MOVE $uid $target");
1✔
170
                } else {
171
                        $connection->command("UID COPY $uid $target");
1✔
172
                        $connection->command("UID STORE $uid +FLAGS.SILENT (\\Deleted)");
1✔
173
                        $this->expunge($uid);
1✔
174
                }
175
        }
1✔
176

177

178
        /**
179
         * Returns the current session number. UIDs are only meaningful within one session,
180
         * so a message from an older one must not be acted upon.
181
         * @internal
182
         */
183
        public function getGeneration(): int
184
        {
185
                return $this->generation;
1✔
186
        }
187

188

189
        /**
190
         * Returns the open connection. Unlike the entry points on the mailbox itself it never
191
         * reconnects: silently opening a new session would resurrect UIDs that no longer mean
192
         * anything, and this is what the destructive operations run on.
193
         * @internal
194
         */
195
        public function getConnection(): Connection
196
        {
197
                return $this->connection ?? throw new Exception('The mailbox is closed');
1✔
198
        }
199

200

201
        private function connectIfNeeded(): Connection
202
        {
203
                if (!$this->connection) {
1✔
204
                        $this->connect();
×
205
                }
206

207
                return $this->connection ?? throw new \LogicException;
1✔
208
        }
209

210

211
        /**
212
         * Removes messages flagged as deleted. Without the UIDPLUS extension the server can only
213
         * expunge the whole folder, which also drops messages flagged by somebody else.
214
         */
215
        private function expunge(int $uid): void
1✔
216
        {
217
                $connection = $this->getConnection();
1✔
218
                $connection->command($connection->hasCapability('UIDPLUS') ? "UID EXPUNGE $uid" : 'EXPUNGE');
1✔
219
        }
1✔
220

221

222
        /** Converts a folder name from UTF-8 to the modified UTF-7 used on the wire. */
223
        private static function encodeName(string $name): string
1✔
224
        {
225
                return mb_convert_encoding($name, 'UTF7-IMAP', 'UTF-8');
1✔
226
        }
227

228

229
        /** Converts a folder name from the modified UTF-7 used on the wire to UTF-8. */
230
        private static function decodeName(string $name): string
1✔
231
        {
232
                return mb_convert_encoding($name, 'UTF-8', 'UTF7-IMAP');
1✔
233
        }
234
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc