Skip to content

8-bit bytes in resp-text abort the response and leave the connection out of step #175

Description

@fain182

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions