Skip to content

Commit b958d74

Browse files
committed
Add ability to lazy load text, html, and attachments
1 parent 977dfc6 commit b958d74

File tree

3 files changed

+334
-2
lines changed

3 files changed

+334
-2
lines changed

src/Message.php

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212

1313
class Message implements Arrayable, JsonSerializable, MessageInterface
1414
{
15-
use HasFlags, HasParsedMessage;
15+
use HasFlags, HasParsedMessage {
16+
text as protected getParsedText;
17+
html as protected getParsedHtml;
18+
attachments as protected getParsedAttachments;
19+
}
1620

1721
/**
1822
* The parsed body structure.
@@ -106,14 +110,19 @@ public function hasBody(): bool
106110
}
107111

108112
/**
109-
* Get the message's body structure.
113+
* Get the message's body structure, fetching it from the server if necessary.
110114
*/
111115
public function bodyStructure(): ?BodyStructureCollection
112116
{
113117
if ($this->bodyStructure) {
114118
return $this->bodyStructure;
115119
}
116120

121+
// If we don't have body structure data, lazy load it from the server.
122+
if (! $this->bodyStructureData) {
123+
$this->bodyStructureData = $this->fetchBodyStructureData();
124+
}
125+
117126
if (! $tokens = $this->bodyStructureData?->tokens()) {
118127
return null;
119128
}
@@ -218,6 +227,67 @@ public function move(string $folder, bool $expunge = false): ?int
218227
}
219228
}
220229

230+
/**
231+
* Get the message's text content.
232+
*/
233+
public function text(bool $lazy = false): ?string
234+
{
235+
if ($lazy && ! $this->hasBody()) {
236+
if ($part = $this->bodyStructure()?->text()) {
237+
return Support\BodyPartDecoder::text($part, $this->bodyPart($part->partNumber()));
238+
}
239+
}
240+
241+
return $this->getParsedText();
242+
}
243+
244+
/**
245+
* Get the message's HTML content.
246+
*/
247+
public function html(bool $lazy = false): ?string
248+
{
249+
if ($lazy && ! $this->hasBody()) {
250+
if ($part = $this->bodyStructure()?->html()) {
251+
return Support\BodyPartDecoder::text($part, $this->bodyPart($part->partNumber()));
252+
}
253+
}
254+
255+
return $this->getParsedHtml();
256+
}
257+
258+
/**
259+
* Get the message's attachments.
260+
*
261+
* @return Attachment[]
262+
*/
263+
public function attachments(bool $lazy = false): array
264+
{
265+
if ($lazy && ! $this->hasBody()) {
266+
return $this->getLazyAttachments();
267+
}
268+
269+
return $this->getParsedAttachments();
270+
}
271+
272+
/**
273+
* Get attachments using lazy loading from body structure.
274+
*
275+
* @return Attachment[]
276+
*/
277+
protected function getLazyAttachments(): array
278+
{
279+
return array_map(
280+
fn (BodyStructurePart $part) => new Attachment(
281+
$part->filename(),
282+
$part->id(),
283+
$part->contentType(),
284+
$part->disposition()?->type(),
285+
new Support\LazyBodyPartStream($this, $part),
286+
),
287+
$this->bodyStructure()?->attachments() ?? []
288+
);
289+
}
290+
221291
/**
222292
* Fetch a specific body part by part number.
223293
*/
@@ -296,4 +366,27 @@ public function isEmpty(): bool
296366
{
297367
return ! $this->hasHead() && ! $this->hasBody();
298368
}
369+
370+
/**
371+
* Fetch the body structure data from the server.
372+
*/
373+
protected function fetchBodyStructureData(): ?ListData
374+
{
375+
$response = $this->folder
376+
->mailbox()
377+
->connection()
378+
->bodyStructure($this->uid);
379+
380+
if ($response->isEmpty()) {
381+
return null;
382+
}
383+
384+
$data = $response->first()->tokenAt(3);
385+
386+
if (! $data instanceof ListData) {
387+
return null;
388+
}
389+
390+
return $data->lookup('BODYSTRUCTURE');
391+
}
299392
}

src/Support/BodyPartDecoder.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace DirectoryTree\ImapEngine\Support;
4+
5+
use DirectoryTree\ImapEngine\BodyStructurePart;
6+
use DirectoryTree\ImapEngine\MessageParser;
7+
8+
class BodyPartDecoder
9+
{
10+
/**
11+
* Decode raw text/html content using the part's metadata.
12+
*/
13+
public static function text(BodyStructurePart $part, ?string $content): ?string
14+
{
15+
$content = rtrim($content ?? '', "\r\n");
16+
17+
if ($content === '') {
18+
return null;
19+
}
20+
21+
$parsed = MessageParser::parse(
22+
MimeMessage::make($part, $content)
23+
);
24+
25+
return $part->subtype() === 'html'
26+
? $parsed->getHtmlContent()
27+
: $parsed->getTextContent();
28+
}
29+
30+
/**
31+
* Decode raw binary content using the part's metadata.
32+
*/
33+
public static function binary(BodyStructurePart $part, ?string $content): ?string
34+
{
35+
$content = rtrim($content ?? '', "\r\n");
36+
37+
if ($content === '') {
38+
return null;
39+
}
40+
41+
$parsed = MessageParser::parse(
42+
MimeMessage::make($part, $content)
43+
);
44+
45+
return $parsed->getBinaryContentStream()?->getContents();
46+
}
47+
}
48+

src/Support/LazyBodyPartStream.php

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<?php
2+
3+
namespace DirectoryTree\ImapEngine\Support;
4+
5+
use DirectoryTree\ImapEngine\BodyStructurePart;
6+
use DirectoryTree\ImapEngine\Message;
7+
use Psr\Http\Message\StreamInterface;
8+
use RuntimeException;
9+
10+
class LazyBodyPartStream implements StreamInterface
11+
{
12+
/**
13+
* The current position in the stream.
14+
*/
15+
protected int $position = 0;
16+
17+
/**
18+
* The cached content.
19+
*/
20+
protected ?string $content = null;
21+
22+
/**
23+
* Constructor.
24+
*/
25+
public function __construct(
26+
protected Message $message,
27+
protected BodyStructurePart $part,
28+
) {}
29+
30+
/**
31+
* Fetch the content from the server if not already cached.
32+
*/
33+
protected function fetchContent(): string
34+
{
35+
if ($this->content === null) {
36+
$this->content = BodyPartDecoder::binary(
37+
$this->part,
38+
$this->message->bodyPart($this->part->partNumber())
39+
) ?? '';
40+
}
41+
42+
return $this->content;
43+
}
44+
45+
/**
46+
* {@inheritDoc}
47+
*/
48+
public function __toString(): string
49+
{
50+
return $this->getContents();
51+
}
52+
53+
/**
54+
* {@inheritDoc}
55+
*/
56+
public function close(): void
57+
{
58+
$this->content = null;
59+
$this->position = 0;
60+
}
61+
62+
/**
63+
* {@inheritDoc}
64+
*/
65+
public function detach()
66+
{
67+
$this->close();
68+
69+
return null;
70+
}
71+
72+
/**
73+
* {@inheritDoc}
74+
*/
75+
public function getSize(): ?int
76+
{
77+
return strlen($this->fetchContent());
78+
}
79+
80+
/**
81+
* {@inheritDoc}
82+
*/
83+
public function tell(): int
84+
{
85+
return $this->position;
86+
}
87+
88+
/**
89+
* {@inheritDoc}
90+
*/
91+
public function eof(): bool
92+
{
93+
return $this->position >= strlen($this->fetchContent());
94+
}
95+
96+
/**
97+
* {@inheritDoc}
98+
*/
99+
public function isSeekable(): bool
100+
{
101+
return true;
102+
}
103+
104+
/**
105+
* {@inheritDoc}
106+
*/
107+
public function seek(int $offset, int $whence = SEEK_SET): void
108+
{
109+
$content = $this->fetchContent();
110+
$size = strlen($content);
111+
112+
$this->position = match ($whence) {
113+
SEEK_SET => $offset,
114+
SEEK_CUR => $this->position + $offset,
115+
SEEK_END => $size + $offset,
116+
default => throw new RuntimeException('Invalid whence'),
117+
};
118+
119+
if ($this->position < 0) {
120+
$this->position = 0;
121+
}
122+
}
123+
124+
/**
125+
* {@inheritDoc}
126+
*/
127+
public function rewind(): void
128+
{
129+
$this->position = 0;
130+
}
131+
132+
/**
133+
* {@inheritDoc}
134+
*/
135+
public function isWritable(): bool
136+
{
137+
return false;
138+
}
139+
140+
/**
141+
* {@inheritDoc}
142+
*/
143+
public function isReadable(): bool
144+
{
145+
return true;
146+
}
147+
148+
/**
149+
* {@inheritDoc}
150+
*/
151+
public function read(int $length): string
152+
{
153+
$content = $this->fetchContent();
154+
155+
$result = substr($content, $this->position, $length);
156+
157+
$this->position += strlen($result);
158+
159+
return $result;
160+
}
161+
162+
/**
163+
* {@inheritDoc}
164+
*/
165+
public function getContents(): string
166+
{
167+
$content = $this->fetchContent();
168+
169+
$result = substr($content, $this->position);
170+
171+
$this->position = strlen($content);
172+
173+
return $result;
174+
}
175+
176+
/**
177+
* {@inheritDoc}
178+
*/
179+
public function getMetadata(?string $key = null): mixed
180+
{
181+
return $key === null ? [] : null;
182+
}
183+
184+
/**
185+
* {@inheritDoc}
186+
*/
187+
public function write(string $string): int
188+
{
189+
throw new RuntimeException('Stream is not writable');
190+
}
191+
}

0 commit comments

Comments
 (0)