Environment
- PHP version: 8.5.9
- ImapEngine version: 1.25.4
- IMAP server: GreenMail 2.1.12 (also reproduced against Dovecot 2.4)
IMAP logs
2026-08-08 10:09:17: << * OK IMAP4rev1 Server GreenMail v2.1.12 ready
2026-08-08 10:09:17: >> TAG1 LOGIN "testuser" "..."
2026-08-08 10:09:17: << TAG1 OK LOGIN completed.
2026-08-08 10:09:17: >> TAG2 DELETE "Missing_&AOAA6ADs-"
DirectoryTree\ImapEngine\Exceptions\ImapParserException: Unexpected byte 0xE0 in response at buffer offset 48
The log stops before the reply because the exception is thrown while reading it. The same exchange on a raw socket, so the response is visible (cat -v, so `M-`` is 0xE0):
C: T1 LOGIN testuser testpass
S: T1 OK LOGIN completed.
C: T2 DELETE "Missing_&AOAA6ADs-"
S: T2 NO DELETE failed. No such folder : Missing_M-`M-hM-l
Reproduction
$mailbox = new Mailbox([...]);
// A folder that does not exist, named with characters outside ASCII.
$name = mb_convert_encoding('Missing_àèì', 'UTF7-IMAP', 'UTF-8');
$mailbox->folders()->find($name)?->delete();
The server rejects the command and quotes the name back in its NO text, decoded from modified UTF-7 into raw 8-bit bytes. ImapTokenizer::isValidAtomCharacter() accepts only 0x20–0x7E, so the first of those bytes ends readAtom() with an empty value and it throws.
Why the byte is there
RFC 3501's resp-text is 7-bit, so on a strict reading the server is at fault. Two things make that not the end of it:
- RFC 6855 permits UTF-8 in responses once
UTF8=ACCEPT is enabled, so 8-bit in this position is legal in a conformant session.
- Servers echo mailbox names into error text without transcoding them, so any account holding a folder with an accent in its name reaches this on the first rejected command that mentions it. GreenMail and Dovecot both do it, with the same command.
Impact
The throw lands in the middle of a response, so what follows stays in the buffer and the connection is left out of step with the wire — the next command reads the tail of the previous reply. Against Dovecot the session ended outright rather than merely erroring. That is a steep penalty for a byte inside human-readable text that nothing parses.
Suggestion
Narrow the rejection to what genuinely cannot appear in an atom — the control range and DEL — and let 0x80–0xFF through:
protected function isValidAtomCharacter(string $char): bool
{
$code = ord($char);
if ($code < 0x20 || $code === 0x7F) {
return false;
}
return ! $this->isDelimiter($char);
}
Every delimiter that ends an atom is ASCII, so admitting the high range widens what an atom may hold without moving where one ends.
This does not reopen #171: that loop spun because readAtom() could consume nothing and return, and control bytes still take the throw. A high byte now makes progress instead, which is the case that was hanging.
Environment
IMAP logs
The log stops before the reply because the exception is thrown while reading it. The same exchange on a raw socket, so the response is visible (
cat -v, so `M-`` is 0xE0):Reproduction
The server rejects the command and quotes the name back in its
NOtext, decoded from modified UTF-7 into raw 8-bit bytes.ImapTokenizer::isValidAtomCharacter()accepts only 0x20–0x7E, so the first of those bytes endsreadAtom()with an empty value and it throws.Why the byte is there
RFC 3501's
resp-textis 7-bit, so on a strict reading the server is at fault. Two things make that not the end of it:UTF8=ACCEPTis enabled, so 8-bit in this position is legal in a conformant session.Impact
The throw lands in the middle of a response, so what follows stays in the buffer and the connection is left out of step with the wire — the next command reads the tail of the previous reply. Against Dovecot the session ended outright rather than merely erroring. That is a steep penalty for a byte inside human-readable text that nothing parses.
Suggestion
Narrow the rejection to what genuinely cannot appear in an atom — the control range and DEL — and let 0x80–0xFF through:
Every delimiter that ends an atom is ASCII, so admitting the high range widens what an atom may hold without moving where one ends.
This does not reopen #171: that loop spun because
readAtom()could consume nothing and return, and control bytes still take the throw. A high byte now makes progress instead, which is the case that was hanging.