65535) { throw new \RuntimeException('imap_configuration_invalid'); } foreach ([IMAP_OPENTIMEOUT, IMAP_READTIMEOUT, IMAP_WRITETIMEOUT, IMAP_CLOSETIMEOUT] as $timeout) { imap_timeout($timeout, 8); } // TLS with certificate validation; never accept /novalidate-cert. $mailbox = '{' . $host . ':' . $port . '/imap/ssl}' . $folder; $connection = @imap_open($mailbox, (string) Settings::get('imap_username'), (string) Settings::get('imap_password'), OP_READONLY, 1); if (!$connection) { imap_errors(); throw new \RuntimeException('imap_connection_failed'); } try { $status = imap_status($connection, $mailbox, SA_UIDVALIDITY | SA_UIDNEXT); if (!$status) { throw new \RuntimeException('imap_status_failed'); } $identity = hash('sha256', $mailbox . ':' . Settings::get('imap_username')); $cursorKey = 'mail_flow_imap_' . $identity; $cursor = admin_setting($cursorKey); if (!$cursor || ($cursor['validity'] ?? null) !== $status->uidvalidity) { // First activation / UIDVALIDITY change: skip historical mail to avoid a reply flood. admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $status->uidnext - 1]]); return 0; } $last = (int) $cursor['uid']; if ($status->uidnext <= $last + 1) { return 0; } // PHP's IMAP search parser does not support the UID search criterion. // Fetch a bounded UID range instead, without downloading message bodies. $upper = min($status->uidnext - 1, $last + 100); $overview = imap_fetch_overview($connection, ($last + 1) . ':' . $upper, FT_UID); if ($overview === false) { throw new \RuntimeException('imap_overview_failed'); } $uids = array_map(static fn ($item) => (int) $item->uid, $overview); sort($uids, SORT_NUMERIC); $count = 0; foreach (array_slice($uids, 0, 100) as $uid) { if ($uid <= $last) { continue; } $raw = imap_fetchheader($connection, $uid, FT_UID); if ($raw === false) { throw new \RuntimeException('imap_header_failed'); } $parsed = imap_rfc822_parse_headers($raw); $from = $parsed->from[0] ?? null; $sender = $from && isset($from->mailbox, $from->host) ? $from->mailbox . '@' . $from->host : ''; $headers = $this->headers($raw); $structure = imap_fetchstructure($connection, $uid, FT_UID); $content = $structure ? $this->content->read($connection, $uid, $structure) : ['text' => '', 'dsn' => '']; $this->content->recordBounce($content['dsn'], $headers); $subject = isset($parsed->subject) ? mb_decode_mimeheader($parsed->subject) : ''; $message = DB::transaction(function () use ($identity, $status, $uid, $sender, $subject, $headers, $content) { $message = MailMessage::firstOrCreate(['message_key' => 'imap:' . hash('sha256', $identity . ':' . $status->uidvalidity . ':' . $uid)], [ 'direction' => 'in', 'purpose' => 'incoming', 'channel' => 'conventional', 'email' => mb_substr($sender, 0, 254), 'subject' => mb_substr($subject, 0, 998), 'provider_id' => isset($headers['message-id']) ? mb_substr($headers['message-id'], 0, 250) : null, 'status' => 'received', 'payload' => ['text' => $content['text']], ]); $reply = $content['dsn'] === '' ? $this->replies->prepare($message, $headers) : null; return $reply; }, 3); // The durable outgoing row is recoverable even if queue dispatch fails. if ($message) { SendMailMessageJob::dispatch($message->id); } admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $uid]]); $count++; } // Deleted UID gaps must not stall subsequent synchronization. admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $upper]]); return $count; } finally { imap_close($connection); imap_errors(); } } private function headers(string $raw): array { $result = []; $raw = preg_replace('/\r?\n[ \t]+/', ' ', $raw); foreach (preg_split('/\r?\n/', $raw) as $line) { if (str_contains($line, ':')) { [$key, $value] = explode(':', $line, 2); $result[strtolower(trim($key))] = trim($value); } } return $result; } }