diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..5e5b915
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+# EditorConfig is awesome: http://EditorConfig.org
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = tab
+indent_size = tab
+tab_width = 4
+
+[*.{json,yaml,yml,md}]
+indent_style = space
+indent_size = 2
diff --git a/.gitattributes b/.gitattributes
index 4f8bb68..aad8529 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,6 +1,10 @@
+.docs export-ignore
+.editorconfig export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.travis.yml export-ignore
-composer.lock export-ignore
-tests/ export-ignore
-*.md export-ignore
\ No newline at end of file
+Makefile export-ignore
+README.md export-ignore
+phpstan.neon export-ignore
+ruleset.xml export-ignore
+tests export-ignore
diff --git a/.github/workflows/codesniffer.yml b/.github/workflows/codesniffer.yml
new file mode 100644
index 0000000..a58ac4f
--- /dev/null
+++ b/.github/workflows/codesniffer.yml
@@ -0,0 +1,18 @@
+name: "Codesniffer"
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+ push:
+ branches: ["*"]
+
+ schedule:
+ - cron: "0 8 * * 1"
+
+jobs:
+ codesniffer:
+ name: "Codesniffer"
+ uses: contributte/.github/.github/workflows/codesniffer.yml@master
+ with:
+ php: "8.2"
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 0000000..fac01f8
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,18 @@
+name: "Coverage"
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+ push:
+ branches: ["*"]
+
+ schedule:
+ - cron: "0 9 * * 1"
+
+jobs:
+ coverage:
+ name: "Nette Tester"
+ uses: contributte/.github/.github/workflows/nette-tester-coverage-v2.yml@master
+ with:
+ php: "8.2"
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
new file mode 100644
index 0000000..13ceb07
--- /dev/null
+++ b/.github/workflows/phpstan.yml
@@ -0,0 +1,18 @@
+name: "Phpstan"
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+ push:
+ branches: ["*"]
+
+ schedule:
+ - cron: "0 10 * * 1"
+
+jobs:
+ phpstan:
+ name: "Phpstan"
+ uses: contributte/.github/.github/workflows/phpstan.yml@master
+ with:
+ php: "8.2"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..ef17693
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,43 @@
+name: "Nette Tester"
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+ push:
+ branches: ["*"]
+
+ schedule:
+ - cron: "0 10 * * 1"
+
+jobs:
+ test85:
+ name: "Nette Tester"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.5"
+
+ test84:
+ name: "Nette Tester"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.4"
+
+ test83:
+ name: "Nette Tester"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.3"
+
+ test82:
+ name: "Nette Tester"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.2"
+
+ testlower:
+ name: "Nette Tester"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.2"
+ composer: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable --prefer-lowest"
diff --git a/.gitignore b/.gitignore
index d72acbc..f0b3670 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,14 @@
-# Folders
+# IDE
+/.idea
+
+# Composer
/vendor
-# Files
/composer.lock
-/tests/*.log
+
+# Tests
+/tests/tmp
+/coverage.*
+/tests/**/*.log
+/tests/**/*.html
+/tests/**/*.expected
+/tests/**/*.actual
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..33bc117
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,34 @@
+.PHONY: install
+install:
+ composer update
+
+.PHONY: qa
+qa: phpstan cs
+
+.PHONY: cs
+cs:
+ifdef GITHUB_ACTION
+ vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp -q --report=checkstyle src tests | cs2pr
+else
+ vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp src tests
+endif
+
+.PHONY: csf
+csf:
+ vendor/bin/phpcbf --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp src tests
+
+.PHONY: phpstan
+phpstan:
+ vendor/bin/phpstan analyse -c phpstan.neon
+
+.PHONY: tests
+tests:
+ vendor/bin/tester -s -p php --colors 1 -C tests/Cases
+
+.PHONY: coverage
+coverage:
+ifdef GITHUB_ACTION
+ vendor/bin/tester -s -p phpdbg --colors 1 -C --coverage coverage.xml --coverage-src src tests/Cases
+else
+ vendor/bin/tester -s -p phpdbg --colors 1 -C --coverage coverage.html --coverage-src src tests/Cases
+endif
diff --git a/composer.json b/composer.json
index 5068dc7..18dc891 100644
--- a/composer.json
+++ b/composer.json
@@ -7,13 +7,33 @@
"authors": [
{
"name": "Milan Felix Sulc",
- "homepage": "http://jfx.cz"
+ "homepage": "https://f3l1x.io"
}
],
+ "require": {
+ "php": ">=8.2"
+ },
"require-dev": {
- "nette/tester": "~1.3.0"
+ "contributte/phpstan": "~0.2.0",
+ "contributte/qa": "~0.4.0",
+ "contributte/tester": "~0.3.0"
},
"autoload": {
- "classmap": ["src/"]
+ "psr-4": {
+ "Minetro\\Imap\\": "src"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\": "tests"
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
}
}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..5011892
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,15 @@
+includes:
+ - vendor/contributte/phpstan/phpstan.neon
+
+parameters:
+ level: 9
+ phpVersion: 80200
+
+ scanDirectories:
+ - src
+
+ fileExtensions:
+ - php
+
+ paths:
+ - src
diff --git a/ruleset.xml b/ruleset.xml
new file mode 100644
index 0000000..3da188c
--- /dev/null
+++ b/ruleset.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tests/tmp
+
diff --git a/src/ImapMessage.php b/src/ImapMessage.php
index d3349d9..f0932ab 100644
--- a/src/ImapMessage.php
+++ b/src/ImapMessage.php
@@ -1,196 +1,170 @@
-
*/
class ImapMessage
{
- // Message flags
- const FLAG_SEEN = '\\Seen';
- const FLAG_ANSWERED = '\\Answered';
- const FLAG_FLAGGED = '\\Flagged';
- const FLAG_DELETED = '\\Deleted';
- const FLAG_DRAFT = '\\Draft';
-
- /** @var int */
- private $number;
-
- /** @var stdClass */
- private $headers;
-
- /** @var stdClass */
- private $structure;
-
- /** @var array */
- private $body = [];
-
- /**
- * @param int $number
- * @param stdClass $headers
- * @param stdClass $structure
- * @param array $body
- */
- function __construct($number, $headers, $structure, array $body)
- {
- $this->number = $number;
-
- // Convert all headers to UTF-8
- $this->headers = $this->utf8($headers);
-
- $this->structure = $structure;
- $this->body = $body;
- }
-
- /**
- * GETTERS *****************************************************************
- * *************************************************************************
- */
-
- /**
- * @return int
- */
- public function getNumber()
- {
- return $this->number;
- }
-
- /**
- * @return stdClass
- */
- public function getHeaders()
- {
- return $this->headers;
- }
-
- /**
- * @return stdClass
- */
- public function getStructure()
- {
- return $this->structure;
- }
-
- /**
- * @return array
- */
- public function getBody()
- {
- return $this->body;
- }
-
- /**
- * @param int $section
- * @return mixed
- * @throws InvalidArgumentException
- */
- public function getBodySection($section)
- {
- if ($section > count($this->body) || !isset($this->body[$section])) {
- throw new InvalidArgumentException('Section #' . $section . ' not found.');
- }
-
- return $this->body[$section];
- }
-
- /**
- * Returns text of the e-mail converted to utf-8.
- *
- * @param int $section
- * @param int $encoding [optional]
- * @return string
- */
- public function getBodySectionText($section, $encoding = NULL)
- {
- $text = $this->getBodySection($section);
- $encoding = $encoding ? $encoding : (isset($this->structure->parts[$section]) ? $this->structure->parts[$section]->encoding : $this->structure->encoding);
-
- switch ($encoding) {
- # 7BIT
- case 0:
- $etext = $text;
- break;
- # 8BIT
- case 1:
- $etext = quoted_printable_decode(imap_8bit($text));
- break;
- # BINARY
- case 2:
- $etext = imap_binary($text);
- break;
- # BASE64
- case 3:
- $etext = imap_base64($text);
- break;
- # QUOTED-PRINTABLE
- case 4:
- $etext = quoted_printable_decode($text);
- break;
- # OTHER
- case 5:
- $etext = $text;
- break;
- # UNKNOWN
- default:
- $etext = $text;
- }
-
- $charset = $this->getBodyCharset($section);
- $charset = $charset ?: mb_detect_encoding($etext, mb_detect_order(), TRUE);
- if ($charset === FALSE) {
- return $etext;
- } else {
- return iconv($charset, "UTF-8//TRANSLIT", $etext);
- }
- }
-
- /**
- * Returns charset defined in e-mail headers.
- *
- * @return string|NULL
- */
- public function getBodyCharset()
- {
- foreach ($this->structure->parameters as $pair) {
- if (isset($pair->attribute) && $pair->attribute == 'charset') {
- return $pair->value;
- }
- }
- return NULL;
- }
-
- /**
- * @return int
- */
- public function countBodies()
- {
- return count($this->body);
- }
-
-
- /**
- * HELPERS *****************************************************************
- * *************************************************************************
- */
-
- /**
- * @param mixed $data
- * @return stdClass
- */
- private function utf8($data)
- {
- $array = json_decode(json_encode($data), TRUE);
- array_walk_recursive($array, function ($v, $k) {
- return is_array($v) ? $v : imap_utf8($v);
- });
-
- return json_decode(json_encode($array));
- }
-}
\ No newline at end of file
+ // Message flags
+ public const FLAG_SEEN = '\\Seen';
+ public const FLAG_ANSWERED = '\\Answered';
+ public const FLAG_FLAGGED = '\\Flagged';
+ public const FLAG_DELETED = '\\Deleted';
+ public const FLAG_DRAFT = '\\Draft';
+
+ private int $number;
+
+ private stdClass $headers;
+
+ private stdClass $structure;
+
+ /** @var array */
+ private array $body = [];
+
+ /**
+ * @param array $body
+ */
+ public function __construct(int $number, stdClass $headers, stdClass $structure, array $body)
+ {
+ $this->number = $number;
+ $this->headers = $this->utf8($headers);
+ $this->structure = $structure;
+ $this->body = $body;
+ }
+
+ public function getNumber(): int
+ {
+ return $this->number;
+ }
+
+ public function getHeaders(): stdClass
+ {
+ return $this->headers;
+ }
+
+ public function getStructure(): stdClass
+ {
+ return $this->structure;
+ }
+
+ /**
+ * @return array
+ */
+ public function getBody(): array
+ {
+ return $this->body;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function getBodySection(int $section): string|false
+ {
+ if ($section > count($this->body) || !isset($this->body[$section])) {
+ throw new InvalidArgumentException('Section #' . $section . ' not found.');
+ }
+
+ return $this->body[$section];
+ }
+
+ /**
+ * Returns text of the e-mail converted to utf-8.
+ */
+ public function getBodySectionText(int $section, ?int $encoding = null): string
+ {
+ $text = (string) $this->getBodySection($section);
+ $encoding ??= (int) (isset($this->structure->parts[$section]) ? $this->structure->parts[$section]->encoding : $this->structure->encoding);
+
+ $etext = match ($encoding) {
+ 0 => $text, // 7BIT
+ 1 => quoted_printable_decode((string) imap_8bit($text)), // 8BIT
+ 2 => (string) imap_binary($text), // BINARY
+ 3 => (string) imap_base64($text), // BASE64
+ 4 => quoted_printable_decode($text), // QUOTED-PRINTABLE
+ default => $text, // OTHER / UNKNOWN
+ };
+
+ $charset = $this->getBodyCharset();
+
+ if ($charset === null) {
+ $detected = mb_detect_encoding($etext, mb_detect_order(), true);
+ $charset = $detected !== false ? $detected : null;
+ }
+
+ if ($charset === null) {
+ return $etext;
+ }
+
+ $result = iconv($charset, 'UTF-8//TRANSLIT', $etext);
+
+ return $result !== false ? $result : $etext;
+ }
+
+ /**
+ * Returns charset defined in e-mail headers.
+ */
+ public function getBodyCharset(): ?string
+ {
+ foreach ($this->structure->parameters as $pair) {
+ if (isset($pair->attribute) && $pair->attribute === 'charset') {
+ return $pair->value; // @phpstan-ignore property.notFound
+ }
+ }
+
+ return null;
+ }
+
+ public function countBodies(): int
+ {
+ return count($this->body);
+ }
+
+ private function utf8(stdClass $data): stdClass
+ {
+ $json = json_encode($data);
+
+ if ($json === false) {
+ return $data;
+ }
+
+ /** @var array $array */
+ $array = json_decode($json, true);
+ $array = $this->utf8Recursive($array);
+
+ $result = json_decode((string) json_encode($array));
+
+ if ($result instanceof stdClass) {
+ return $result;
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param array $array
+ * @return array
+ */
+ private function utf8Recursive(array $array): array
+ {
+ $result = [];
+
+ foreach ($array as $key => $value) {
+ if (is_array($value)) {
+ $result[$key] = $this->utf8Recursive($value);
+ } elseif (is_string($value)) {
+ $result[$key] = imap_utf8($value);
+ } else {
+ $result[$key] = $value;
+ }
+ }
+
+ return $result;
+ }
+
+}
diff --git a/src/ImapReader.php b/src/ImapReader.php
index f1010f7..6ad75a5 100644
--- a/src/ImapReader.php
+++ b/src/ImapReader.php
@@ -1,205 +1,177 @@
-
*/
final class ImapReader
{
- // Message criteria
- const CRITERIA_ALL = 'ALL'; // Return all messages matching the rest of the criteria
- const CRITERIA_ANSWERED = 'ANSWERED'; // Match messages with the \\ANSWERED flag set
- const CRITERIA_BCC = 'BCC'; // Match messages with "string" in the Bcc: field
- const CRITERIA_BEFORE = 'BEFORE'; // Match messages with Date: before "date"
- const CRITERIA_BODY = 'BODY'; // Match messages with "string" in the body of the message
- const CRITERIA_CC = 'CC'; // Match messages with "string" in the Cc: field
- const CRITERIA_DELETED = 'DELETED'; // Match deleted messages
- const CRITERIA_FLAGGED = 'FLAGGED'; // Match messages with the \\FLAGGED (sometimes referred to as Important or Urgent) flag set
- const CRITERIA_FROM = 'FROM'; // Match messages with "string" in the From: field
- const CRITERIA_KEYWORD = 'KEYWORD'; // Match messages with "string" as a keyword
- const CRITERIA_NEW = 'NEW'; // Match new messages
- const CRITERIA_OLD = 'OLD'; // Match old messages
- const CRITERIA_ON = 'ON'; // Match messages with Date: matching "date"
- const CRITERIA_RECENT = 'RECENT'; // Match messages with the \\RECENT flag set
- const CRITERIA_SEEN = 'SEEN'; // Match messages that have been read (the \\SEEN flag is set)
- const CRITERIA_SINCE = 'SINCE'; // Match messages with Date: after "date"
- const CRITERIA_SUBJECT = 'SUBJECT'; // Match messages with "string" in the Subject:
- const CRITERIA_TEXT = 'TEXT'; // Match messages with teconst CRITERIA_t = 't'; "string"
- const CRITERIA_TO = 'TO'; // Match messages with "string" in the To:
- const CRITERIA_UNANSWERED = 'UNANSWERED'; // Match messages that have not been answered
- const CRITERIA_UNDELETED = 'UNDELETED'; // Match messages that are not deleted
- const CRITERIA_UNFLAGGED = 'UNFLAGGED'; // Match messages that are not flagged
- const CRITERIA_UNKEYWORD = 'UNKEYWORD'; // Match messages that do not have the keyword "string"
- const CRITERIA_UNSEEN = 'UNSEEN'; // Match messages which have not been read yet
-
- /** @var resource */
- private $imap;
-
- /** @var string */
- private $mailbox;
-
- /** @var string */
- private $username;
-
- /** @var string */
- private $password;
-
- /**
- * Create IMAP resource
- *
- * @param string $mailbox
- * @param string $username
- * @param string $password
- */
- function __construct($mailbox, $username, $password)
- {
- $this->mailbox = $mailbox;
- $this->username = $username;
- $this->password = $password;
-
- $this->connect($mailbox, $username, $password);
- }
-
- /**
- * Close IMAP resource
- *
- * @return void
- */
- function __destruct()
- {
- $this->disconnect();
- }
-
- /**
- * API *********************************************************************
- * *************************************************************************
- */
-
- /**
- * @return bool
- */
- public function isAlive()
- {
- return (bool)@imap_ping($this->imap);
- }
-
- /**
- * @param string $criteria
- * @param int $options
- * @return ImapMessage[]
- */
- public function read($criteria = 'ALL', $options = SE_FREE)
- {
- $mails = imap_search($this->imap, $criteria, $options);
- if (!$mails) return [];
-
- $emails = [];
- foreach ($mails as $mailnum) {
- $structure = imap_fetchstructure($this->imap, $mailnum);
- $headers = imap_headerinfo($this->imap, $mailnum);
- if (!$structure || !$headers) continue;
-
- $sections = [];
- if (isset($structure->parts)) {
- foreach ($structure->parts as $partnum => $part) {
- $sections[] = imap_fetchbody($this->imap, $mailnum, $partnum);
- }
- } else {
- $sections[] = imap_body($this->imap, $mailnum);
- }
-
- $emails[] = new ImapMessage($mailnum, $headers, $structure, $sections);
- }
-
- return $emails;
- }
-
- /**
- * Change IMAP reading folder
- *
- * @param string $folder
- * @return void
- */
- public function folder($folder)
- {
- // @todo
- }
-
- /**
- * Set message flag
- *
- * @param string|array $sequence Message numbers
- * @param string|array $flag Message flag
- * @return bool
- */
- public function flag($sequence, $flag)
- {
- $sequence = is_array($sequence) ? implode(',', $sequence) : $sequence;
- $flag = is_array($flag) ? implode(' ', $flag) : $flag;
- return imap_setflag_full($this->imap, $sequence, $flag);
- }
-
- /**
- * Unset message flag
- *
- * @param string|array $sequence Message numbers
- * @param string|array $flag Message flag
- * @return bool
- */
- public function unflag($sequence, $flag)
- {
- $sequence = is_array($sequence) ? implode(',', $sequence) : $sequence;
- $flag = is_array($flag) ? implode(' ', $flag) : $flag;
- return imap_clearflag_full($this->imap, $sequence, $flag);
- }
-
- /**
- * MAGIC *******************************************************************
- * *************************************************************************
- */
-
- /**
- * @param string $name
- * @param array $arguments
- * @return mixed
- */
- function __call($name, $arguments)
- {
- $args = $arguments;
- array_unshift($args, $this->imap);
- return call_user_func_array('imap_' . $name, $args);
- }
-
- /**
- * HELPERS *****************************************************************
- * *************************************************************************
- */
-
- /**
- * Connect to IMAP mailbox
- *
- * @return void
- */
- protected function connect()
- {
- $this->imap = imap_open($this->mailbox, $this->username, $this->password);
- }
-
- /**
- * Disconnect to IMAP mailbox
- *
- * @return void
- */
- protected function disconnect()
- {
- // Flush all messages out
- imap_errors();
- imap_alerts();
-
- imap_close($this->imap);
- }
-}
\ No newline at end of file
+ // Message criteria
+ public const CRITERIA_ALL = 'ALL';
+ public const CRITERIA_ANSWERED = 'ANSWERED';
+ public const CRITERIA_BCC = 'BCC';
+ public const CRITERIA_BEFORE = 'BEFORE';
+ public const CRITERIA_BODY = 'BODY';
+ public const CRITERIA_CC = 'CC';
+ public const CRITERIA_DELETED = 'DELETED';
+ public const CRITERIA_FLAGGED = 'FLAGGED';
+ public const CRITERIA_FROM = 'FROM';
+ public const CRITERIA_KEYWORD = 'KEYWORD';
+ public const CRITERIA_NEW = 'NEW';
+ public const CRITERIA_OLD = 'OLD';
+ public const CRITERIA_ON = 'ON';
+ public const CRITERIA_RECENT = 'RECENT';
+ public const CRITERIA_SEEN = 'SEEN';
+ public const CRITERIA_SINCE = 'SINCE';
+ public const CRITERIA_SUBJECT = 'SUBJECT';
+ public const CRITERIA_TEXT = 'TEXT';
+ public const CRITERIA_TO = 'TO';
+ public const CRITERIA_UNANSWERED = 'UNANSWERED';
+ public const CRITERIA_UNDELETED = 'UNDELETED';
+ public const CRITERIA_UNFLAGGED = 'UNFLAGGED';
+ public const CRITERIA_UNKEYWORD = 'UNKEYWORD';
+ public const CRITERIA_UNSEEN = 'UNSEEN';
+
+ private Connection $imap;
+
+ private string $mailbox;
+
+ private string $username;
+
+ private string $password;
+
+ public function __construct(string $mailbox, string $username, string $password)
+ {
+ $this->mailbox = $mailbox;
+ $this->username = $username;
+ $this->password = $password;
+
+ $this->connect();
+ }
+
+ public function __destruct()
+ {
+ $this->disconnect();
+ }
+
+ public function isAlive(): bool
+ {
+ return @imap_ping($this->imap);
+ }
+
+ /**
+ * @return ImapMessage[]
+ */
+ public function read(string $criteria = 'ALL', int $options = SE_FREE): array
+ {
+ $mails = imap_search($this->imap, $criteria, $options);
+
+ if ($mails === false) {
+ return [];
+ }
+
+ $emails = [];
+
+ foreach ($mails as $mailnum) {
+ $structure = imap_fetchstructure($this->imap, $mailnum);
+ $headers = imap_headerinfo($this->imap, $mailnum);
+
+ if ($structure === false || $headers === false) {
+ continue;
+ }
+
+ $sections = [];
+
+ if (isset($structure->parts)) {
+ foreach ($structure->parts as $partnum => $part) {
+ $sections[] = imap_fetchbody($this->imap, $mailnum, (string) $partnum);
+ }
+ } else {
+ $sections[] = imap_body($this->imap, $mailnum);
+ }
+
+ $emails[] = new ImapMessage($mailnum, $headers, $structure, $sections);
+ }
+
+ return $emails;
+ }
+
+ /**
+ * Change IMAP reading folder
+ */
+ public function folder(string $folder): void
+ {
+ // @todo
+ }
+
+ /**
+ * Set message flag
+ *
+ * @param string|array $sequence Message numbers
+ * @param string|array $flag Message flag
+ */
+ public function flag(string|array $sequence, string|array $flag): bool
+ {
+ $sequence = is_array($sequence) ? implode(',', $sequence) : $sequence;
+ $flag = is_array($flag) ? implode(' ', $flag) : $flag;
+
+ return imap_setflag_full($this->imap, $sequence, $flag);
+ }
+
+ /**
+ * Unset message flag
+ *
+ * @param string|array $sequence Message numbers
+ * @param string|array $flag Message flag
+ */
+ public function unflag(string|array $sequence, string|array $flag): bool
+ {
+ $sequence = is_array($sequence) ? implode(',', $sequence) : $sequence;
+ $flag = is_array($flag) ? implode(' ', $flag) : $flag;
+
+ return imap_clearflag_full($this->imap, $sequence, $flag);
+ }
+
+ /**
+ * Connect to IMAP mailbox
+ */
+ protected function connect(): void
+ {
+ $connection = imap_open($this->mailbox, $this->username, $this->password);
+
+ if ($connection === false) {
+ throw new RuntimeException('Failed to connect to IMAP mailbox');
+ }
+
+ $this->imap = $connection;
+ }
+
+ /**
+ * Disconnect from IMAP mailbox
+ */
+ protected function disconnect(): void
+ {
+ // Flush all messages out
+ imap_errors();
+ imap_alerts();
+
+ imap_close($this->imap);
+ }
+
+ /**
+ * @param array $arguments
+ */
+ public function __call(string $name, array $arguments): mixed
+ {
+ $args = $arguments;
+ array_unshift($args, $this->imap);
+
+ return call_user_func_array('imap_' . $name, $args); // @phpstan-ignore argument.type
+ }
+
+}
diff --git a/tests/Cases/ImapMessageTest.phpt b/tests/Cases/ImapMessageTest.phpt
new file mode 100644
index 0000000..e9a0cff
--- /dev/null
+++ b/tests/Cases/ImapMessageTest.phpt
@@ -0,0 +1,108 @@
+attribute = 'charset';
+ $param->value = $charset;
+
+ $structure = new stdClass();
+ $structure->parameters = [$param];
+ $structure->encoding = $encoding;
+
+ return $structure;
+}
+
+function createHeaders(): stdClass
+{
+ $headers = new stdClass();
+ $headers->subject = 'Test Subject';
+ $headers->from = 'sender@example.com';
+ $headers->to = 'recipient@example.com';
+
+ return $headers;
+}
+
+// Tests
+
+Toolkit::test(function (): void {
+ $headers = createHeaders();
+ $structure = createStructure();
+ $body = ['Section 0', 'Section 1'];
+
+ $message = new ImapMessage(1, $headers, $structure, $body);
+
+ Assert::same(1, $message->getNumber());
+ Assert::type(stdClass::class, $message->getHeaders());
+ Assert::same($structure, $message->getStructure());
+ Assert::same($body, $message->getBody());
+ Assert::same(2, $message->countBodies());
+});
+
+Toolkit::test(function (): void {
+ $message = new ImapMessage(42, createHeaders(), createStructure(), ['body0', 'body1', 'body2']);
+
+ Assert::same('body0', $message->getBodySection(0));
+ Assert::same('body1', $message->getBodySection(1));
+ Assert::same('body2', $message->getBodySection(2));
+});
+
+Toolkit::test(function (): void {
+ $message = new ImapMessage(1, createHeaders(), createStructure(), ['only']);
+
+ Assert::exception(function () use ($message): void {
+ $message->getBodySection(5);
+ }, InvalidArgumentException::class, 'Section #5 not found.');
+});
+
+Toolkit::test(function (): void {
+ $message = new ImapMessage(1, createHeaders(), createStructure('UTF-8'), []);
+
+ Assert::same('UTF-8', $message->getBodyCharset());
+});
+
+Toolkit::test(function (): void {
+ $structure = new stdClass();
+ $structure->parameters = [];
+ $structure->encoding = 0;
+
+ $message = new ImapMessage(1, createHeaders(), $structure, []);
+
+ Assert::null($message->getBodyCharset());
+});
+
+Toolkit::test(function (): void {
+ $structure = createStructure('UTF-8', 0);
+ $message = new ImapMessage(1, createHeaders(), $structure, ['Hello World']);
+
+ Assert::same('Hello World', $message->getBodySectionText(0, 0));
+});
+
+Toolkit::test(function (): void {
+ $message = new ImapMessage(1, createHeaders(), createStructure(), []);
+
+ Assert::same(0, $message->countBodies());
+});
+
+Toolkit::test(function (): void {
+ Assert::same('\\Seen', ImapMessage::FLAG_SEEN);
+ Assert::same('\\Answered', ImapMessage::FLAG_ANSWERED);
+ Assert::same('\\Flagged', ImapMessage::FLAG_FLAGGED);
+ Assert::same('\\Deleted', ImapMessage::FLAG_DELETED);
+ Assert::same('\\Draft', ImapMessage::FLAG_DRAFT);
+});
diff --git a/tests/Cases/ImapReaderTest.phpt b/tests/Cases/ImapReaderTest.phpt
new file mode 100644
index 0000000..6aef024
--- /dev/null
+++ b/tests/Cases/ImapReaderTest.phpt
@@ -0,0 +1,41 @@
+