transport, $configuration->host, $configuration->port, $configuration->timeout, $configuration->verifyPeer, $configuration->verifyPeerName, $configuration->allowSelfSigned, ); if (null !== $logger) { $connection = new TraceableConnection($connection, $logger); } $this->configuration = $configuration; $this->imap = new Imap($connection); $this->selectedMailbox = new Mailbox([], '', ''); } public static function create(Configuration $configuration, ?LoggerInterface $logger = null): self { return new self($configuration, $logger); } public function connect(): void { $this->imap->connect(); } /** * Perform STARTTLS negotiation (patch). * * Call after connect() but before logIn(). The underlying Imap protocol * layer sends the STARTTLS command and upgrades the socket to TLS. */ public function startTls(): void { $this->imap->startTls(); } public function disconnect(): void { $this->imap->disconnect(); } public function logIn(string $username, string $password): void { $this->send(new LogInCommand($username, $password)); } public function authenticate(SASLMechanism $mechanism): void { $this->send(new AuthenticateCommand($mechanism)); } /** * @return array */ public function mailboxes(string $referenceName = '', string $pattern = '*'): array { $response = $this->send(new ListCommand($referenceName, $pattern)); return array_map( fn (ListData $data) => new Mailbox($data->nameAttributes, $data->hierarchyDelimiter, $data->name), $response->getData(ListData::class), ); } public function select(Mailbox|string $mailbox): Mailbox { if (is_string($mailbox)) { $mailbox = new Mailbox([], '', $mailbox); } $response = $this->send(new SelectCommand($mailbox->name)); if ($flagsData = $response->getData(FlagsData::class)[0] ?? null) { $mailbox->flags = $flagsData->flags; } if ($existsData = $response->getData(ExistsData::class)[0] ?? null) { $mailbox->exists = $existsData->numberOfMessages; } if ($recentData = $response->getData(RecentData::class)[0] ?? null) { $mailbox->recent = $recentData->numberOfMessages; } foreach ($response->getData(Status::class) as $status) { if ($status->code instanceof UnseenCode) { $mailbox->unseen = $status->code->seq; } elseif ($status->code instanceof UidValidityCode) { $mailbox->uidValidity = $status->code->value; } elseif ($status->code instanceof UidNextCode) { $mailbox->uidNext = $status->code->value; } elseif ($status->code instanceof PermanentFlagsCode) { $mailbox->permanentFlags = $status->code->flags; } } return $this->selectedMailbox = $mailbox; } public function search(): Search { return new Search($this); } /** * @throws MessageNotFound */ public function fetch(int $id): Message { $response = $this->imap->send( new FetchCommand( $this->configuration->useUid, new SequenceSet($id), ['INTERNALDATE', 'BODY[HEADER]', 'BODYSTRUCTURE'] ) ); $data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound(); if (null === $internalDate = $data->internalDate) { throw new Exception('Unable to fetch internal date from message '.$id); } if (null === $part = $data->bodyStructure?->part) { throw new Exception('Unable to fetch body structure from message '.$id); } return new Message( $id, $this->createHeaders($data) ?? [], $this->createMessagePart($id, '0', $part), $internalDate, ); } /** * Stream FetchData for a specific set of UIDs, one response line at a time. * * Uses the same sendStreaming path as fetchMultiple() so responses are * processed as they arrive off the socket without buffering the entire * server reply. Items can be tailored per call-site; defaults to a rich * set that populates EntityResource fully (flags, envelope, body structure, * size, arrival date). * * @param int[] $uids * @param string[] $items IMAP fetch data items * @return Generator Yields uid => FetchData */ public function streamByUids( array $uids, array $items = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'], ): Generator { $gen = $this->imap->sendStreaming( new FetchCommand( $this->configuration->useUid, new SequenceSet(...$uids), $items, ) ); foreach ($gen as $line) { if (!$line instanceof FetchData) { continue; } $id = $line->id; if ($this->configuration->useUid) { $id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id); } yield $id => $line; } } /** * Stream messages from a sequence range as a Generator, yielding each * LazyMessage as soon as its FETCH response line arrives off the socket — * without waiting for the entire batch to complete. * * Usage with an NDJSON HTTP response: * * foreach ($client->fetchMultiple(1, 50) as $message) { * echo json_encode($message) . "\n"; * flush(); * } * * @param int $from First sequence number (inclusive) * @param int $to Last sequence number (inclusive) * @return Generator */ public function fetchMultiple(int $from, int $to): Generator { $items = ['FLAGS', 'INTERNALDATE', 'BODY[HEADER]']; $gen = $this->imap->sendStreaming( new FetchCommand( $this->configuration->useUid, SequenceSet::range($from, $to), $items, ) ); foreach ($gen as $line) { if (!$line instanceof FetchData) { continue; } $id = $line->id; if ($this->configuration->useUid) { $id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id); } yield new LazyMessage( $this, $id, $this->createHeaders($line), $line->internalDate, ); } } /** * @return array * @throws MessageNotFound */ public function fetchHeaders(int $id): array { $response = $this->imap->send( new FetchCommand( $this->configuration->useUid, new SequenceSet($id), ['BODY[HEADER]'] ) ); /** @var FetchData $data */ $data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound(); return $this->createHeaders($data) ?? []; } public function fetchBody(int $id): Part { $response = $this->send( new FetchCommand( $this->configuration->useUid, new SequenceSet($id), ['BODYSTRUCTURE'] ) ); $data = $response->getData(FetchData::class)[0]; if (null === $part = $data->bodyStructure?->part) { throw new Exception('Unable to fetch body from message '.$id); } return $this->createMessagePart($id, '0', $part); } public function fetchInternalDate(int $id): DateTimeImmutable { $response = $this->send( new FetchCommand( $this->configuration->useUid, new SequenceSet($id), ['INTERNALDATE'] ) ); $data = $response->getData(FetchData::class)[0]; if (null === $internalDate = $data->internalDate) { throw new Exception('Unable to fetch internal date from message '.$id); } return $internalDate; } public function fetchSectionBody(int $id, string $section): string { $response = $this->send( new FetchCommand( $this->configuration->useUid, new SequenceSet($id), ["BODY[$section]"] ) ); $data = $response->getData(FetchData::class)[0]; return $data->getBodySection($section)?->text ?? ''; } public function deleteMessage(Message|int $message): void { $id = $message instanceof Message ? $message->id() : $message; $this->send( new StoreCommand( $this->configuration->useUid, new SequenceSet($id), new Flags(['\Deleted'], '+') ) ); $this->send(new ExpungeCommand()); } public function createMailbox(string $name): void { $this->send(new CreateCommand($name)); } /** * @param list|null $flags */ public function append( string $message, string $mailbox = 'INBOX', ?array $flags = null, ?DateTimeInterface $internalDate = null ): int { $response = $this->send(new AppendCommand($mailbox, $message, $flags, $internalDate)); $code = $response->status->code; if ($code instanceof AppendUidCode) { return $code->uid; } throw new RuntimeException('Unable to retrieve uid from append response'); } public function send(Command $command): Response { $this->imap->connect(); return $this->imap->send($command); } /** * @param array $criteria * @return array */ public function doSearch(array $criteria, ?PreFetchOptions $preFetchOptions = null): array { $response = $this->send( new Protocol\Command\SearchCommand( $this->configuration->useUid, ...$criteria ) ); $ids = []; foreach ($response->data as $data) { if ($data instanceof SearchData) { array_push($ids, ...$data->numbers); } } if (empty($ids)) { return []; } if (null !== $preFetchOptions) { $items = []; if ($preFetchOptions->headers) { $items[] = 'BODY[HEADER]'; } if ($preFetchOptions->internalDate) { $items[] = 'INTERNALDATE'; } $preFetchResult = $this->send(new FetchCommand( $this->configuration->useUid, new SequenceSet(...$ids), $items, )); $messages = []; foreach ($preFetchResult->data as $data) { if ($data instanceof FetchData) { $id = $data->id; if ($this->configuration->useUid) { $id = $data->uid ?? throw new RuntimeException('Unable to get uid from message '.$id); } $messages[] = new LazyMessage( $this, $id, $this->createHeaders($data), $data->internalDate, ); } } return $messages; } return array_map(fn (int $id) => new LazyMessage($this, $id), $ids); } /** * @return array|null */ private function createHeaders(FetchData $data): ?array { if (null === $headerSection = $data->getBodySection('HEADER')) { return null; } return iconv_mime_decode_headers($headerSection->text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR) ?: []; } private function createMessagePart(int $id, string $section, BodyStructure\Part $part): Mime\Part\Part { if ($part instanceof BodyStructure\SinglePart) { return new SinglePart( $part->type, $part->subtype, $part->attributes, new LazyBody($this, $id, $section === '0' ? '1' : $section), $part->attributes['charset'] ?? 'utf-8', $part->encoding, null !== $part->disposition ? new Disposition( $part->disposition->type, $part->disposition->attributes['filename'] ?? null ) : null, ); } if (!$part instanceof BodyStructure\MultiPart) { throw new Exception('Unable to create message part from body structure part of class '.$part::class); } $childParts = []; foreach ($part->parts as $index => $childPart) { $childIndex = (string) ($index + 1); $childSection = $section === '0' ? $childIndex : $section.'.'.$childIndex; $childParts[] = $this->createMessagePart($id, $childSection, $childPart); } return new MultiPart($part->subtype, $part->attributes, $childParts); } }