1<?php 2 3class MailConnecter 4{ 5 private $fp = null; 6 private static $instance; 7 private static $init = false; 8 9 private const HOST = 'localhost'; 10 private const PORT = 143; 11 private const TIMEOUT = 10; 12 13 private function __construct() 14 { 15 if (($this->fp = @fsockopen(self::HOST, self::PORT)) === false) { 16 die('cannot open imap socket'); 17 } 18 } 19 20 public static function getConnection(): self 21 { 22 if (!static::$init) { 23 static::$instance = new self(); 24 } 25 26 return static::$instance; 27 } 28 29 public function disconnect(): void 30 { 31 fclose($this->fp); 32 } 33 34 public function fail(string $message): void 35 { 36 $this->disconnect(); 37 die($message); 38 } 39 40 public function send(string $tag, string $command): void 41 { 42 fputs($this->fp, "{$tag} {$command}\r\n"); 43 } 44 45 public function isSuccess(string $tag): bool 46 { 47 $start = time(); 48 while (!feof($this->fp)) { 49 $line = fgets($this->fp); 50 if (!$line) { 51 return false; 52 } 53 if (str_contains($line, $tag)) { 54 if (preg_match('/(NO|BAD|failed|Failure)/i', $line)) { 55 return false; 56 } 57 return true; 58 } 59 if (time() - $start > self::TIMEOUT) { 60 $this->fail("{$tag} timeout"); 61 } 62 } 63 return false; 64 } 65 66 public function getResponse(string $tag, bool $returnArray = false): string|array 67 { 68 $start = time(); 69 $output = $returnArray ? [] : ''; 70 while (!feof($this->fp)) { 71 $line = fgets($this->fp); 72 if (!$line) { 73 $this->fail("{$tag} failed"); 74 } 75 if ($returnArray) { 76 $output[] = $line; 77 } else { 78 $output .= $line; 79 } 80 if (str_contains($line, $tag)) { 81 if (preg_match('/(NO|BAD|failed|Failure)/i', $line)) { 82 $this->fail("{$tag} failed"); 83 } 84 return $output; 85 } 86 if (time() - $start > self::TIMEOUT) { 87 $this->fail("{$tag} timeout"); 88 } 89 } 90 $this->fail("{$tag} failed"); 91 } 92} 93 94class MailBox 95{ 96 private MailConnecter $mailConnecter; 97 private const PASSWORD = 'p4ssw0rd'; 98 public const USERS = [ 99 'webmaster@example.com', 100 'info@example.com', 101 'admin@example.com', 102 'foo@example.com', 103 ]; 104 105 private const LOGIN = 'A00001'; 106 private const LOGOUT = 'A00002'; 107 private const SELECT_MAILBOX = 'A00003'; 108 private const SEARCH = 'A00004'; 109 private const FETCH_HEADERS = 'A00005'; 110 private const FETCH_BODY = 'A00006'; 111 private const DELETE = 'A00007'; 112 private const EXPUNGE = 'A00008'; 113 114 private function __construct() 115 { 116 $this->mailConnecter = MailConnecter::getConnection(); 117 } 118 119 public static function login(string $user): self 120 { 121 $self = new self(); 122 $self->mailConnecter->send(self::LOGIN, 'LOGIN '.$user.' '.self::PASSWORD); 123 if (!$self->mailConnecter->isSuccess(self::LOGIN)) { 124 $self->mailConnecter->fail('login failed'); 125 } 126 return $self; 127 } 128 129 public function logout(): void 130 { 131 $this->mailConnecter->send(self::LOGOUT, 'LOGOUT'); 132 if (!$this->mailConnecter->isSuccess(self::LOGOUT)) { 133 $this->mailConnecter->fail('logout failed'); 134 } 135 } 136 137 private function getUidsBySubject(string $subject): array 138 { 139 $this->mailConnecter->send(self::SELECT_MAILBOX, 'SELECT "INBOX"'); 140 if (!$this->mailConnecter->isSuccess(self::SELECT_MAILBOX)) { 141 $this->mailConnecter->fail('select mailbox failed'); 142 } 143 144 $this->mailConnecter->send(self::SEARCH, "UID SEARCH SUBJECT \"{$subject}\""); 145 $res = $this->mailConnecter->getResponse(self::SEARCH); 146 preg_match('/SEARCH ([0-9 ]+)/is', $res, $matches); 147 return isset($matches[1]) ? explode(' ', trim($matches[1])) : []; 148 } 149 150 public function getMailsBySubject(string $subject): MailCollection 151 { 152 return new MailCollection(array_map( 153 fn($uid) => $this->getHeaders($uid) + ['Body' => $this->getBody($uid)], 154 $this->getUidsBySubject($subject), 155 )); 156 } 157 158 private function getHeaders(int $uid): array 159 { 160 $this->mailConnecter->send(self::FETCH_HEADERS, "UID FETCH {$uid} (BODY[HEADER])"); 161 $res = $this->mailConnecter->getResponse(self::FETCH_HEADERS, true); 162 163 $headers = []; 164 foreach ($res as $line) { 165 $line = trim($line); 166 if (!$line) { 167 continue; 168 } 169 $items = explode(':', $line); 170 preg_match('/^(.+?):(.+?)$/', $line, $matches); 171 $key = trim($matches[1] ?? ''); 172 $val = trim($matches[2] ?? ''); 173 if (!$key || !$val || $val === self::FETCH_HEADERS.' OK UID completed' || $val === ')') { 174 continue; 175 } 176 177 $headers[$key] = $val; 178 } 179 return $headers; 180 } 181 182 private function getBody(int $uid): string 183 { 184 $this->mailConnecter->send(self::FETCH_BODY, "UID FETCH {$uid} (BODY[TEXT])"); 185 $body = $this->mailConnecter->getResponse(self::FETCH_BODY, true); 186 $count = count($body); 187 if ($count <= 3) { 188 return ''; 189 } 190 191 return implode('', array_slice($body, 1, $count - 3)); 192 } 193 194 public function deleteMailsBySubject(string $subject): void 195 { 196 array_map( 197 fn($uid) => $this->mailConnecter->send(self::DELETE, "UID STORE {$uid} +FLAGS (\\Deleted)"), 198 $this->getUidsBySubject($subject), 199 ); 200 $this->mailConnecter->send(self::EXPUNGE, 'EXPUNGE'); 201 } 202} 203 204class MailCollection 205{ 206 public function __construct(private ?array $mailData) {} 207 208 public function isAsExpected(string $from, string $to, string $subject, string $message): bool 209 { 210 $result = true; 211 212 if (!$this->mailData) { 213 $result = false; 214 echo "Email data does not exist.\n"; 215 } 216 if (!$this->count() > 1) { 217 $result = false; 218 echo "Multiple email data exist.\n"; 219 } 220 if ($this->getHeader('From', true) !== $from) { 221 $result = false; 222 echo "from does not match.\n"; 223 } 224 if ($this->getHeader('To', true) !== $to) { 225 $result = false; 226 echo "to does not match.\n"; 227 } 228 if ($this->getHeader('Subject', true) !== $subject) { 229 $result = false; 230 echo "subject does not match.\n"; 231 } 232 if (trim($this->getBody()) !== trim($message)) { 233 $result = false; 234 echo "body does not match.\n"; 235 } 236 237 return $result; 238 } 239 240 public function count(): int 241 { 242 return count($this->mailData); 243 } 244 245 public function getHeader(string $field, bool $ignoreCases = false, int $offset = 0) 246 { 247 if ($ignoreCases) { 248 $mail = array_change_key_case($this->mailData[$offset] ?? []); 249 $field = strtolower($field); 250 } else { 251 $mail = $this->mailData[$offset] ?? []; 252 } 253 254 return $mail[$field] ?? null; 255 } 256 257 public function getBody(int $offset = 0): ?string 258 { 259 return $this->mailData[$offset]['Body'] ?? null; 260 } 261} 262