Skip to content

Commit ed95f3e

Browse files
committed
Add partial body fetching and chunked attachment streams
1 parent f80844d commit ed95f3e

11 files changed

Lines changed: 410 additions & 56 deletions

File tree

‎readme.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,35 @@
1616
<p align="center">
1717
<a href="https://imapengine.com">View Documentation</a>
1818
</p>
19+
20+
### Partial body fetching
21+
22+
Fetch a byte range of a message section without downloading the whole section:
23+
24+
```php
25+
$messages = $folder->messages()
26+
->with(MessageData::text()->partial(0, 4096)->peek())
27+
->get();
28+
29+
$preview = $messages->first()->data()->get('BODY[TEXT]<0>');
30+
31+
$chunk = $message->bodyPart('2', offset: 65536, length: 65536);
32+
```
33+
34+
Offsets are zero-based and refer to transfer-encoded bytes. The server may return
35+
fewer bytes at the end of a section, or an empty string beyond its end. A missing
36+
message or section returns `null`. Partial data does not replace the complete body.
37+
38+
Attachments obtained through `attachments(fetch: true)` download in 64 KiB chunks
39+
as their streams are read. Base64 and quoted-printable decoding continues across
40+
chunk boundaries. Decoded content is cached in a temporary stream that spills to
41+
disk beyond 2 MiB, allowing seeks and repeated reads without fetching again.
42+
43+
```php
44+
$attachment = $message->attachments(fetch: true)[0];
45+
$attachment->save('/path/to/attachment.pdf');
46+
```
47+
48+
`save()` copies stream chunks directly to the destination. Calling `contents()` or
49+
casting a stream to a string still allocates the requested contents in memory.
50+
The decoded stream size is unknown (`null`) until the section has been fully read.

‎src/Attachment.php‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace DirectoryTree\ImapEngine;
44

5+
use DirectoryTree\ImapEngine\Support\LazyBodyPartStream;
56
use GuzzleHttp\Psr7\Utils;
67
use Illuminate\Contracts\Support\Arrayable;
78
use JsonSerializable;
@@ -63,7 +64,7 @@ public static function lazy(Message $message): array
6364
$part->id(),
6465
$part->contentType(),
6566
$part->disposition()?->type()?->value,
66-
new Support\LazyBodyPartStream($message, $part),
67+
new LazyBodyPartStream($message, $part),
6768
);
6869
}
6970

@@ -137,7 +138,23 @@ public function contentStream(): StreamInterface
137138
*/
138139
public function save(string $path): false|int
139140
{
140-
return file_put_contents($path, $this->contents());
141+
if ($this->contentStream->isSeekable()) {
142+
$this->contentStream->rewind();
143+
}
144+
145+
if (! $resource = fopen($path, 'wb')) {
146+
return false;
147+
}
148+
149+
$destination = Utils::streamFor($resource);
150+
151+
try {
152+
Utils::copyToStream($this->contentStream, $destination);
153+
154+
return $destination->tell();
155+
} finally {
156+
$destination->close();
157+
}
141158
}
142159

143160
/**

‎src/FileMessage.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public function hasBodyStructure(): bool
9292
/**
9393
* {@inheritDoc}
9494
*/
95-
public function bodyPart(string $partNumber, bool $peek = true): ?string
95+
public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string
9696
{
9797
throw new BadMethodCallException('FileMessage does not support fetching body parts');
9898
}

‎src/Message.php‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
99
use DirectoryTree\ImapEngine\Connection\Responses\MessageResponseParser;
1010
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
11+
use DirectoryTree\ImapEngine\Support\BodyPartDecoder;
1112
use DirectoryTree\ImapEngine\Support\Str;
1213
use Illuminate\Contracts\Support\Arrayable;
14+
use InvalidArgumentException;
1315
use JsonSerializable;
1416
use ZBateson\MailMimeParser\Header\DateHeader;
1517
use ZBateson\MailMimeParser\Header\HeaderConsts;
@@ -398,7 +400,7 @@ public function text(bool $fetch = false): ?string
398400
{
399401
if ($fetch && ! $this->hasBody()) {
400402
if ($part = $this->bodyStructure(fetch: true)?->text()) {
401-
return Support\BodyPartDecoder::text($part, $this->bodyPart($part->partNumber()));
403+
return BodyPartDecoder::text($part, $this->bodyPart($part->partNumber()));
402404
}
403405
}
404406

@@ -416,7 +418,7 @@ public function html(bool $fetch = false): ?string
416418
{
417419
if ($fetch && ! $this->hasBody()) {
418420
if ($part = $this->bodyStructure(fetch: true)?->html()) {
419-
return Support\BodyPartDecoder::text($part, $this->bodyPart($part->partNumber()));
421+
return BodyPartDecoder::text($part, $this->bodyPart($part->partNumber()));
420422
}
421423
}
422424

@@ -466,25 +468,39 @@ public function attachmentCount(): int
466468
}
467469

468470
/**
469-
* Fetch a specific body part by part number.
471+
* Fetch a body section, optionally limited to a transfer-encoded byte range.
470472
*/
471-
public function bodyPart(string $partNumber, bool $peek = true): ?string
473+
public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string
472474
{
473-
$key = "BODY[$partNumber]";
475+
$item = MessageData::section($partNumber);
474476

475-
if ($peek && $this->data->has($key)) {
477+
if ($length !== null) {
478+
$item = $item->partial($offset, $length);
479+
} elseif ($offset !== 0) {
480+
throw new InvalidArgumentException('A partial fetch requires a length.');
481+
}
482+
483+
$key = $item->key();
484+
485+
if ($length === null && $peek && $this->data->has($key)) {
476486
return $this->data->get($key);
477487
}
478488

489+
if ($length !== null) {
490+
$this->folder->select();
491+
}
492+
479493
$response = $this->folder->mailbox()
480494
->connection()
481-
->fetch($this->uid(), $peek ? "BODY.PEEK[$partNumber]" : "BODY[$partNumber]");
495+
->fetch($this->uid(), ($peek ? $item->peek() : $item)->toImap());
482496

483497
if (! $data = $response->messages()[0] ?? null) {
484498
return null;
485499
}
486500

487-
$this->data = $this->data->merge($data);
501+
if ($length === null) {
502+
$this->data = $this->data->merge($data);
503+
}
488504

489505
return $data->get($key);
490506
}

‎src/MessageData/Body.php‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,36 @@
22

33
namespace DirectoryTree\ImapEngine\MessageData;
44

5+
use InvalidArgumentException;
6+
57
class Body implements FetchItemInterface
68
{
9+
/**
10+
* The zero-based offset in the transfer-encoded section, or null for a full fetch.
11+
*/
12+
protected ?int $offset = null;
13+
14+
/**
15+
* The maximum number of transfer-encoded bytes to fetch.
16+
*/
17+
protected ?int $length = null;
18+
19+
/**
20+
* Fetch a byte range of the transfer-encoded section.
21+
*/
22+
public function partial(int $offset, int $length): static
23+
{
24+
if ($offset < 0 || $length < 1) {
25+
throw new InvalidArgumentException('Partial fetches require a non-negative offset and a positive length.');
26+
}
27+
28+
$item = clone $this;
29+
$item->offset = $offset;
30+
$item->length = $length;
31+
32+
return $item;
33+
}
34+
735
/**
836
* Constructor.
937
*/
@@ -52,7 +80,7 @@ public function peek(): static
5280
*/
5381
public function key(): string
5482
{
55-
return "BODY[{$this->section}]";
83+
return "BODY[{$this->section}]".(is_null($this->offset) ? '' : "<{$this->offset}>");
5684
}
5785

5886
/**
@@ -62,6 +90,6 @@ public function toImap(): string
6290
{
6391
$item = $this->peek ? 'BODY.PEEK' : 'BODY';
6492

65-
return "{$item}[{$this->section}]";
93+
return "{$item}[{$this->section}]".(is_null($this->offset) ? '' : "<{$this->offset}.{$this->length}>");
6694
}
6795
}

‎src/MessageInterface.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,9 @@ public function bodyStructure(): ?BodyStructureCollection;
143143
public function hasBodyStructure(): bool;
144144

145145
/**
146-
* Fetch a specific body part by part number.
146+
* Fetch a body section, optionally limited to a transfer-encoded byte range.
147147
*/
148-
public function bodyPart(string $partNumber, bool $peek = true): ?string;
148+
public function bodyPart(string $partNumber, bool $peek = true, int $offset = 0, ?int $length = null): ?string;
149149

150150
/**
151151
* Determine if the message is the same as another message.

0 commit comments

Comments
 (0)