• 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

86.84
/src/Message.php
1
<?php declare(strict_types=1);
2

3
namespace DG\Imap;
4

5
use function count;
6

7

8
/**
9
 * Represents an individual email message within an IMAP mailbox.
10
 */
11
final class Message
12
{
13
        private ?string $body = null;
14

15
        /** @var ?list<string>  raw top-level MIME parts */
16
        private ?array $parts = null;
17

18
        private bool $removed = false;
19

20

21
        /**
22
         * @param  array<string, string>  $headers
23
         * @internal
24
         */
25
        public function __construct(
1✔
26
                private ?Mailbox $mailbox,
27
                private int $generation,
28
                private int $uid,
29
                private array $headers,
30
        ) {
31
        }
1✔
32

33

34
        /**
35
         * Creates a message from a raw RFC 5322 string, e.g. the contents of an .eml file.
36
         * Such a message is not bound to a mailbox and cannot be deleted.
37
         */
38
        public static function fromString(string $raw): self
1✔
39
        {
40
                [$headers, $body] = MimeParser::parse($raw);
1✔
41
                $message = new self(null, 0, 0, $headers);
1✔
42
                $message->processBody($body);
1✔
43
                return $message;
1✔
44
        }
45

46

47
        /**
48
         * Returns the subject of the message, decoded for readability. Malformed input,
49
         * such as a raw 8-bit subject some senders emit, is returned as-is.
50
         */
51
        public function getSubject(): string
52
        {
53
                return self::decodeHeader($this->headers['subject'] ?? '');
1✔
54
        }
55

56

57
        /**
58
         * Returns the sender's information of the message, decoded for readability.
59
         */
60
        public function getFrom(): string
61
        {
62
                return self::decodeHeader($this->headers['from'] ?? '');
1✔
63
        }
64

65

66
        /**
67
         * Returns the date of the message, or null when the Date header is missing, empty or malformed.
68
         */
69
        public function getDate(): ?\DateTimeImmutable
70
        {
71
                $date = trim($this->headers['date'] ?? ''); // an empty value would parse as 'now'
1✔
72
                try {
73
                        return $date === '' ? null : new \DateTimeImmutable($date);
1✔
74
                } catch (\Throwable) {
1✔
75
                        return null;
1✔
76
                }
77
        }
78

79

80
        /**
81
         * Returns the raw content of the message body: the first part for a multipart message,
82
         * the whole body otherwise. Transfer encoding is not decoded.
83
         */
84
        public function getContents(): string
85
        {
86
                $this->fetchBody();
1✔
87
                return $this->parts
1✔
88
                        ? MimeParser::parse($this->parts[0])[1]
1✔
89
                        : ($this->body ?? '');
1✔
90
        }
91

92

93
        /**
94
         * Removes the message from the mailbox right away. What that means is the server's policy:
95
         * a plain IMAP server destroys it, Gmail applies the Auto-Expunge setting of the account.
96
         * Use trash() or archive() when the outcome has to be the same everywhere.
97
         */
98
        public function delete(): void
99
        {
100
                $this->requireMailbox()->deleteMessage($this->uid);
1✔
101
                $this->removed = true;
1✔
102
        }
1✔
103

104

105
        /**
106
         * Moves the message to the folder the server designates as trash, so it is removed from
107
         * the mailbox but stays recoverable until the server's retention period ends.
108
         * @throws Exception  The server advertises no trash folder.
109
         */
110
        public function trash(): void
111
        {
112
                $this->moveTo(
1✔
113
                        $this->requireMailbox()->getSpecialFolder('\Trash')
1✔
114
                        ?? throw new Exception('The server advertises no trash folder, use moveTo() with an explicit name'),
1✔
115
                );
116
        }
1✔
117

118

119
        /**
120
         * Moves the message to the folder the server designates as archive, so it disappears from
121
         * the mailbox but is not deleted. On Gmail this is All Mail.
122
         * @throws Exception  The server advertises no archive folder.
123
         */
124
        public function archive(): void
125
        {
126
                $mailbox = $this->requireMailbox();
1✔
127
                $this->moveTo(
1✔
128
                        $mailbox->getSpecialFolder('\Archive')
1✔
129
                        ?? $mailbox->getSpecialFolder('\All')
1✔
130
                        ?? throw new Exception('The server advertises no archive folder, use moveTo() with an explicit name'),
1✔
131
                );
132
        }
1✔
133

134

135
        /**
136
         * Moves the message to the given folder, named in UTF-8.
137
         */
138
        public function moveTo(string $folder): void
1✔
139
        {
140
                $this->requireMailbox()->moveMessage($this->uid, $folder);
1✔
141
                $this->removed = true;
1✔
142
        }
1✔
143

144

145
        /**
146
         * Counts and returns the number of parts in the message.
147
         */
148
        public function countParts(): int
149
        {
150
                $this->fetchBody();
1✔
151
                return count($this->parts ?? []);
1✔
152
        }
153

154

155
        /**
156
         * Retrieves a specific part of the message.
157
         */
158
        public function getPart(int $id): MessagePart
1✔
159
        {
160
                $this->fetchBody();
1✔
161
                $part = $this->parts[$id] ?? null;
1✔
162
                if ($part === null) {
1✔
163
                        throw new \ValueError('Invalid part number');
1✔
164
                }
165

166
                return new MessagePart($part);
1✔
167
        }
168

169

170
        /**
171
         * Returns an array of all parts of the message.
172
         * @return list<MessagePart>
173
         */
174
        public function getParts(): array
175
        {
176
                $this->fetchBody();
1✔
177
                $res = [];
1✔
178
                foreach ($this->parts ?? [] as $n => $foo) {
1✔
179
                        $res[] = $this->getPart($n);
×
180
                }
181

182
                return $res;
1✔
183
        }
184

185

186
        /** Decodes encoded-words (=?utf-8?B?...?=); malformed input is returned as-is. */
187
        private static function decodeHeader(string $value): string
1✔
188
        {
189
                $decoded = @iconv_mime_decode($value); // @ - malformed input falls back to the raw value
1✔
190
                return $decoded === false ? $value : $decoded;
1✔
191
        }
192

193

194
        /**
195
         * @throws \LogicException  The message is detached or no longer in the mailbox.
196
         */
197
        private function requireMailbox(): Mailbox
198
        {
199
                if ($this->removed) {
1✔
200
                        throw new \LogicException('Message is no longer in the mailbox');
1✔
201
                }
202

203
                $mailbox = $this->mailbox ?? throw new \LogicException('Message is not bound to a mailbox');
1✔
204
                if ($mailbox->getGeneration() !== $this->generation) {
1✔
205
                        throw new Exception('Message comes from an earlier session, its UID is no longer valid');
1✔
206
                }
207

208
                return $mailbox;
1✔
209
        }
210

211

212
        /**
213
         * Fetches and caches the body of the message. Once cached it stays readable even after the
214
         * message was removed from the mailbox; only a fetch that still has to happen fails.
215
         */
216
        private function fetchBody(): void
217
        {
218
                if ($this->parts !== null) {
1✔
219
                        return;
1✔
220
                }
221

222
                $connection = $this->requireMailbox()->getConnection();
×
223
                foreach ($connection->command("UID FETCH {$this->uid} (BODY.PEEK[])") as $line) {
×
224
                        $parsed = str_starts_with($line, '* ') ? Connection::parseLiteral($line) : null;
×
225
                        if ($parsed) {
×
226
                                [$headers, $body] = MimeParser::parse($parsed[0]);
×
227
                                $this->headers += $headers;
×
228
                                $this->processBody($body);
×
229
                                return;
×
230
                        }
231
                }
232

233
                throw new Exception('Cannot fetch message');
×
234
        }
235

236

237
        private function processBody(string $body): void
1✔
238
        {
239
                [$type, $params] = MimeParser::parseHeaderValue($this->headers['content-type'] ?? '');
1✔
240
                $boundary = $params['BOUNDARY'] ?? null;
1✔
241
                $this->parts = str_starts_with($type, 'multipart/') && $boundary !== null && $boundary !== ''
1✔
242
                        ? MimeParser::splitMultipart($body, $boundary)
1✔
243
                        : [];
1✔
244
                $this->body = $body;
1✔
245
        }
1✔
246
}
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