<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library\mail;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\models\MailAccountModel;
/**
* MailBackend implementation that talks IMAP4rev1 to a remote
* server. Used for accounts the user has configured under Mail
* Settings -> External Mail Accounts.
*
* Connection lifecycle: the IMAP socket is opened lazily on the
* first method call that needs the server, and reused across
* subsequent calls. Callers should put close() in a finally block
* so the socket is released even if a method throws. The cached
* client tracks the currently-SELECT'd folder so adjacent calls
* on the same folder skip the SELECT round-trip.
*
* IMAP-protocol primitives this backend needs (OpenImap, LOGIN,
* LIST, FETCH BODY.PEEK, UID STORE flag, COPY/EXPUNGE for moves,
* etc.) live here as private methods rather than being delegated
* back into SocialComponent. SocialComponent still has its own
* copies of these helpers during the in-progress backend
* migration; once every handler has been ported to use this
* backend, the SocialComponent copies will be deleted.
*
* Read state semantics: fetchMessage uses BODY.PEEK so the
* implicit \Seen-on-FETCH behaviour of plain RFC822/BODY[] does
* not fire. Callers explicitly call setFlag($folder, $uid,
* '\\Seen', true) when they want to mark-as-read; this gives
* IMAP and MailSite identical semantics from the caller's point
* of view.
*
* @author Chris Pollett
*/
class ImapMailBackend extends MailBackend
{
/**
* @var array account row as returned by
* MailAccountModel::getAccountWithPassword, with HOST,
* PORT, TLS_MODE, USERNAME, PASSWORD, DISPLAY_NAME, etc.
*/
protected $account;
/**
* @var object|null cached IMAP client, opened on first use,
* released on close(). Null when no connection is open.
*/
protected $client;
/**
* @var string|null name of the folder currently SELECT'd on
* $client, used to avoid redundant SELECT round-trips
* when adjacent calls target the same folder. Null when
* no SELECT has been issued yet.
*/
protected $selected_folder;
/**
* @var object component for userMailLog access; the backend
* writes the same imap-* log lines the old inline code
* did so MAIL_LOG_ENABLED output looks identical
* regardless of which code path made the IMAP calls
*/
protected $component;
/**
* @var bool|null whether the server advertises the SORT
* extension, learned from one CAPABILITY check and kept for
* the life of this backend so a page of listing does not
* ask twice. Null until first checked.
*/
protected $sort_supported;
/**
* @param array $account account row with credentials
* @param object $component the SocialComponent instance for
* userMailLog access; the backend writes the same
* imap-* instrumentation lines the old inline code did
*/
public function __construct($account, $component)
{
$this->account = $account;
$this->client = null;
$this->selected_folder = null;
$this->component = $component;
$this->sort_supported = null;
}
/**
* {@inheritdoc}
*/
public function accountId()
{
return (int) $this->account['ID'];
}
/**
* {@inheritdoc}
*/
public function displayName()
{
return $this->account['DISPLAY_NAME'] ?? '';
}
/**
* {@inheritdoc}
*/
public function senderEmail()
{
return MailAccountModel::senderEmail($this->account);
}
/**
* {@inheritdoc}
*/
public function isSyntheticAccount()
{
return false;
}
/**
* Returns the folder this account opens to by default, the one
* the user configured, or the standard inbox when none is set.
*
* @return string the folder name to open by default
*/
public function defaultFolder()
{
return ($this->account["DEFAULT_FOLDER"] ?? "") ?: "INBOX";
}
/**
* {@inheritdoc}
*/
public function listFolders()
{
try {
$client = $this->client();
$list = $this->imapListAll($client);
if ($list['status'] !== 'OK') {
throw new \Exception("LIST failed: " .
($list['detail'] ?? ''));
}
return ImapFolderListParser::sortBySpecialUse(
ImapFolderListParser::parse($list['untagged']));
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* How long a folder's unread count is trusted before the server
* is asked again, in seconds. Short, because unread counts change
* as mail arrives and is read, but long enough that paging around
* the inbox does not re-probe every folder on each page.
*/
const MAIL_FOLDER_UNREAD_TTL = 60;
/**
* Fills in each folder's unread-message count for the sidebar.
* Recently probed folders are read from a short-lived per-account
* cache; the rest are asked of the server. A count that cannot be
* obtained simply leaves that folder without a badge rather than
* failing the whole sidebar.
*
* @param array &$folders the folder tree to annotate in place;
* each selectable folder gets an UNREAD entry when known
* @throws MailBackendException on transport failure
*/
public function annotateUnreadCounts(&$folders)
{
try {
$client = $this->client();
$account_id = $this->accountId();
if (empty($_SESSION["MAIL_FOLDER_UNREAD"][$account_id])) {
$_SESSION["MAIL_FOLDER_UNREAD"][$account_id] = [];
}
$cache = &$_SESSION["MAIL_FOLDER_UNREAD"][$account_id];
$now = time();
$stats = ['cache_hits' => 0, 'status_ok' => 0,
'status_fail' => 0, 'fail_samples' => []];
$started = microtime(true);
$this->walkFolderUnread($client, $folders, $cache, $now,
$stats);
$summary = ['cache_hits' => $stats['cache_hits'],
'status_ok' => $stats['status_ok'],
'status_fail' => $stats['status_fail'],
'elapsed_ms' => (int) ((microtime(true) - $started) *
1000)];
if (!empty($stats['fail_samples'])) {
$summary['fails'] = implode("|",
$stats['fail_samples']);
}
$this->component->userMailLog('imap-probe-unread',
$summary);
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* Walks the folder tree and sets each selectable folder's unread
* count, asking the server only for folders whose cached count
* has expired or is missing. Failures for one folder are tallied
* and skipped so the rest of the tree still gets counts. Runs
* over child folders first so nested mailboxes are covered too.
*
* @param object $client the live IMAP client to ask
* @param array &$folders the folders at this level, annotated in
* place with an UNREAD count
* @param array &$cache the per-account unread cache, read and
* updated as folders are probed
* @param int $now the current time used to expire cache entries
* @param array &$stats running counters and failure samples for
* the log line the caller writes
*/
protected function walkFolderUnread($client, &$folders, &$cache,
$now, &$stats)
{
foreach ($folders as &$folder) {
if (!empty($folder['CHILDREN'])) {
$this->walkFolderUnread($client, $folder['CHILDREN'],
$cache, $now, $stats);
}
if (empty($folder['SELECTABLE'])) {
continue;
}
$name = $folder['NAME'];
$cached = $cache[$name] ?? null;
if ($cached !== null &&
isset($cached['unread'], $cached['expires']) &&
$cached['expires'] > $now) {
$folder['UNREAD'] = (int) $cached['unread'];
$stats['cache_hits']++;
continue;
}
try {
$status = $client->send('STATUS "' .
str_replace(['\\', '"'], ['\\\\', '\\"'],
$name) . '" (UNSEEN)');
} catch (\Exception $exception) {
$stats['status_fail']++;
if (count($stats['fail_samples']) < 3) {
$stats['fail_samples'][] = $name . "=exc:" .
substr($exception->getMessage(), 0, 60);
}
continue;
}
if ($status['status'] !== 'OK') {
$stats['status_fail']++;
if (count($stats['fail_samples']) < 3) {
$stats['fail_samples'][] = $name . "=" .
$status['status'] . ":" . substr(
$status['detail'] ?? '', 0, 60);
}
continue;
}
$stats['status_ok']++;
$unread = 0;
foreach ($status['untagged'] as $line) {
if (preg_match('/UNSEEN\s+(\d+)/i', $line,
$matches)) {
$unread = (int) $matches[1];
break;
}
}
$folder['UNREAD'] = $unread;
$cache[$name] = ['unread' => $unread,
'expires' => $now + self::MAIL_FOLDER_UNREAD_TTL];
}
}
/**
* {@inheritdoc}
*/
public function createFolder($name)
{
$this->folderOp('create', null, $name);
}
/**
* {@inheritdoc}
*/
public function renameFolder($from, $to)
{
$this->folderOp('rename', $from, $to);
}
/**
* {@inheritdoc}
*/
public function deleteFolder($name)
{
$this->folderOp('delete', $name, null);
}
/**
* Lists one window of a folder's messages for the inbox view,
* honoring the sort, the search filter, and the unread-only and
* flagged-only toggles the request carries, and supporting both
* the first page and later "load more" windows. This is the one
* listing entry the inbox uses for an IMAP account; the matching
* method on the MailSite backend serves local accounts, so the
* caller does not special-case account type.
*
* The request is an array with: "window" (how many messages to
* return), "sort" (a "key"/"reverse" pair), "filter" (a free-text
* term, or empty), "unread_only" and "flagged_only" (the
* toggles), "unreadable_subject" (already-translated text for a
* message that cannot be fetched), and "cursor" (null for the
* first page, or, for a later window, the cursor this method
* returned as "next_cursor" last time).
*
* The result is an array with: "messages" (the rows, with raw
* subject and sender for the caller to clean), "total" (how many
* messages the count should show), "sort" (the sort actually
* applied, which may differ from the request when a sort was too
* large to run), "sort_supported" (whether the server can sort by
* subject and sender), "sort_oversized" (true when a requested
* sort was too large and the default newest-first view was shown
* instead), "mode" ("range" for the default newest-first paging
* by sequence number, "sorted" for paging by position in a sorted
* list), "next_cursor" (pass back to get the following window, or
* null when there is no more), and "has_more" (whether another
* window exists).
*
* @param string $folder the folder to list
* @param array $request the listing request described above
* @return array the listing result described above
*/
public function listMessages($folder, $request)
{
try {
$window = (int) ($request['window'] ?? 25);
$sort = $request['sort'] ??
['key' => 'date', 'reverse' => false];
$filter = (string) ($request['filter'] ?? '');
$unread_only = !empty($request['unread_only']);
$flagged_only = !empty($request['flagged_only']);
$unreadable = (string)
($request['unreadable_subject'] ?? '');
$cursor = $request['cursor'] ?? null;
/* if the caller already knows this server's sort support
(it remembered the answer from an earlier page), take
it on trust and skip asking again. */
if (isset($request['sort_supported'])) {
$this->sort_supported =
(bool) $request['sort_supported'];
}
$select_client = $this->client();
$select_response = $select_client->send('SELECT "' .
self::imapQuote($folder) . '"');
if (($select_response['status'] ?? '') !== 'OK') {
$this->selected_folder = null;
throw new \Exception("SELECT $folder failed: " .
($select_response['detail'] ?? ''));
}
$this->selected_folder = $folder;
$exists = 0;
foreach ($select_response['untagged'] as $select_line) {
if (preg_match('/^\* (\d+) EXISTS/', $select_line,
$select_match)) {
$exists = (int) $select_match[1];
break;
}
}
/* learn the server's sort support once, right after
selecting, so the command order is the same whichever
listing path runs and later checks cost nothing. */
$this->supportsSort();
$is_default = ($filter === '' && !$unread_only &&
!$flagged_only && ($sort['key'] ?? 'date') === 'date'
&& empty($sort['reverse']));
if ($exists <= 0) {
return ['messages' => [], 'total' => 0,
'sort' => $sort,
'sort_supported' => $this->supportsSort(),
'sort_oversized' => false, 'mode' => 'range',
'next_cursor' => null, 'has_more' => false];
}
if ($cursor === null) {
if ($is_default) {
return $this->firstRangeWindow($exists, $window,
$sort, $unreadable);
}
return $this->firstSortedWindow($folder, $exists,
$window, $sort, $filter, $unread_only,
$flagged_only, $unreadable);
}
if (($cursor['mode'] ?? 'range') === 'sorted') {
$sequence = $cursor['sequence'] ?? [];
$offset = (int) ($cursor['offset'] ?? 0);
$next = array_slice($sequence, $offset, $window);
$rows = empty($next) ? [] :
$this->adaptiveFetch($next, $unreadable);
$new_offset = $offset + count($rows);
$has_more = $new_offset < count($sequence);
return ['messages' => $rows, 'total' => 0,
'sort' => $sort,
'sort_supported' => $this->supportsSort(),
'sort_oversized' => false, 'mode' => 'sorted',
'next_cursor' => $has_more ? ['mode' => 'sorted',
'offset' => $new_offset,
'sequence' => $sequence] : null,
'has_more' => $has_more];
}
$before = (int) ($cursor['before'] ?? 1);
$upper = $before - 1;
$from = max(1, $before - $window);
$rows = array_reverse($this->adaptiveFetch(
range($from, $upper), $unreadable));
return ['messages' => $rows, 'total' => 0,
'sort' => $sort,
'sort_supported' => $this->supportsSort(),
'sort_oversized' => false, 'mode' => 'range',
'next_cursor' => $from > 1 ?
['mode' => 'range', 'before' => $from] : null,
'has_more' => $from > 1];
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* Returns the first, newest-first window of a folder by fetching
* the contiguous block of sequence numbers that ends at the last
* message. This is the fast default path: no sort or search is
* sent to the server.
*
* @param int $exists how many messages the folder holds
* @param int $window how many to return
* @param array $sort the sort in effect (default date order)
* @param string $unreadable placeholder subject text
* @return array the listing result
*/
protected function firstRangeWindow($exists, $window, $sort,
$unreadable)
{
$from = max(1, $exists - $window + 1);
$rows = array_reverse($this->adaptiveFetch(
range($from, $exists), $unreadable));
return ['messages' => $rows, 'total' => $exists,
'sort' => $sort, 'sort_supported' => $this->supportsSort(),
'sort_oversized' => false, 'mode' => 'range',
'next_cursor' => $from > 1 ?
['mode' => 'range', 'before' => $from] : null,
'has_more' => $from > 1];
}
/**
* Returns the first window of a folder under a sort, a filter, or
* a toggle that the default fast path cannot serve. It asks the
* server to build the ordered list of matching sequence numbers
* (via SORT or SEARCH), then fetches the first window of them.
* When the sort is too large to run, it quietly falls back to the
* default newest-first view and reports that in the result.
*
* @param string $folder the folder being listed
* @param int $exists how many messages the folder holds
* @param int $window how many to return
* @param array $sort the requested sort
* @param string $filter the free-text filter, or empty
* @param bool $unread_only the unread-only toggle
* @param bool $flagged_only the flagged-only toggle
* @param string $unreadable placeholder subject text
* @return array the listing result
*/
protected function firstSortedWindow($folder, $exists, $window,
$sort, $filter, $unread_only, $flagged_only, $unreadable)
{
$criteria = ImapListing::searchCriteria($filter,
$unread_only, $flagged_only);
if ($this->supportsSort()) {
$plan = ImapListing::serverSortPlan($sort);
if ($plan['mode'] === 'search') {
$response = $this->client()->send(
"SEARCH $criteria", C\MAIL_SORT_TIMEOUT);
$built = ['list' => array_reverse(
ImapResponseParser::parseSearch(
$response['untagged']))];
} else {
$response = $this->client()->send(
"SORT (" . $plan['args'] . ") UTF-8 $criteria",
C\MAIL_SORT_TIMEOUT);
$built = ['list' => ImapResponseParser::parseSort(
$response['untagged'])];
}
$built['oversized'] = false;
} else {
$built = $this->fallbackSort($criteria, $sort);
}
if (!empty($built['oversized'])) {
$default = ['key' => 'date', 'reverse' => false];
$result = $this->firstRangeWindow($exists, $window,
$default, $unreadable);
$result['sort_oversized'] = true;
return $result;
}
$sequence = $built['list'];
$first = array_slice($sequence, 0, $window);
$rows = empty($first) ? [] :
$this->adaptiveFetch($first, $unreadable);
$offset = count($rows);
$total = $filter !== '' ? count($sequence) : $exists;
$has_more = $offset < count($sequence);
return ['messages' => $rows, 'total' => $total,
'sort' => $sort, 'sort_supported' => $this->supportsSort(),
'sort_oversized' => false, 'mode' => 'sorted',
'next_cursor' => $has_more ? ['mode' => 'sorted',
'offset' => $offset, 'sequence' => $sequence] : null,
'has_more' => $has_more];
}
/**
* Reports whether the server can sort messages for us, by asking
* once for its capabilities and remembering the answer for the
* life of this backend. When the answer is no, the listing code
* sorts in PHP instead.
*
* @return bool true when the server advertises the SORT extension
*/
protected function supportsSort()
{
if ($this->sort_supported !== null) {
return $this->sort_supported;
}
$client = $this->client();
$capability = $client->send('CAPABILITY');
if (($capability['status'] ?? '') !== 'OK') {
/* a dead or unhappy connection: do not remember a "no",
so the next request can try again, and fall back to
PHP-side sorting for now. */
return false;
}
$this->sort_supported = ImapResponseParser::hasCapability(
$capability['untagged'], 'SORT');
return $this->sort_supported;
}
/**
* Orders a matching set in PHP for servers without the SORT
* extension. A SEARCH gives the matching messages; date order
* then comes free from sequence order, while subject or sender
* order needs the relevant header fetched and compared. To keep
* a huge folder from stalling the page, a subject or sender sort
* over more than MAIL_PHP_SORT_MAX matches is declined and
* reported as oversized so the caller can fall back.
*
* @param string $criteria the SEARCH criteria to narrow the set
* @param array $sort the requested sort
* @return array ["list" => ordered sequence numbers,
* "oversized" => bool]
*/
protected function fallbackSort($criteria, $sort)
{
$response = $this->client()->send("SEARCH $criteria",
C\MAIL_SORT_TIMEOUT);
$ids = ImapResponseParser::parseSearch($response['untagged']);
if (empty($ids)) {
return ['list' => [], 'oversized' => false];
}
if (($sort['key'] ?? 'date') === 'date') {
return ['list' => ImapListing::fallbackDateOrder($ids,
!empty($sort['reverse'])), 'oversized' => false];
}
if (count($ids) > C\MAIL_PHP_SORT_MAX) {
return ['list' => [], 'oversized' => true];
}
$field = $sort['key'] === 'subject' ? 'SUBJECT' : 'FROM';
$response = $this->client()->send("FETCH " .
implode(',', $ids) .
" (BODY.PEEK[HEADER.FIELDS ($field)])",
C\MAIL_SORT_TIMEOUT);
$values = $this->parseHeaderField($response['untagged'],
$field);
return ['list' => ImapListing::fallbackFieldOrder($ids,
$values, $sort['key'], !empty($sort['reverse'])),
'oversized' => false];
}
/**
* Reads one header field out of a FETCH reply that asked for
* just that field, returning a map from sequence number to the
* field's value with the "FieldName:" label and surrounding
* space removed and folded lines joined. The PHP-side sort uses
* this to gather the values it compares.
*
* @param array $untagged the FETCH reply lines
* @param string $field the field name, upper-case, as requested
* @return array a map of sequence number to header value
*/
protected function parseHeaderField($untagged, $field)
{
$values = [];
$current_seq = 0;
$collecting = false;
$buffer = '';
foreach ($untagged as $line) {
if (preg_match('/^\* (\d+) FETCH/', $line, $match)) {
if ($collecting && $current_seq > 0) {
$values[$current_seq] = trim($buffer);
}
$current_seq = (int) $match[1];
$buffer = '';
$collecting = true;
if (preg_match('/' . preg_quote($field, '/') .
':\s*(.*)$/i', $line, $field_match)) {
$buffer = $field_match[1];
}
continue;
}
if ($collecting && ($line === ')' || $line === '')) {
continue;
}
if ($collecting) {
$buffer .= ' ' . trim($line);
}
}
if ($collecting && $current_seq > 0) {
$values[$current_seq] = trim($buffer);
}
return $values;
}
/**
* {@inheritdoc}
*/
public function fetchMessage($folder, $uid)
{
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$fetch = $client->send("UID FETCH $uid BODY.PEEK[]");
if ($fetch['status'] !== 'OK' ||
empty($fetch['literals'])) {
throw new \Exception(tl(
'mail_backend_message_not_found', $uid));
}
return reset($fetch['literals']);
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function messageHeaderBytes($folder, $uid)
{
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$fetch = $client->send(
"UID FETCH $uid BODY.PEEK[HEADER]");
if ($fetch['status'] !== 'OK' ||
empty($fetch['literals'])) {
throw new \Exception(tl(
'mail_backend_message_not_found', $uid));
}
return reset($fetch['literals']);
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function messageCount($folder, $unread_only = false)
{
try {
$client = $this->client();
$items = $unread_only ? '(UNSEEN)' : '(MESSAGES)';
$status = $client->send('STATUS "' .
self::imapQuote($folder) . "\" $items");
if ($status['status'] !== 'OK') {
throw new \Exception("STATUS failed: " .
($status['detail'] ?? ''));
}
$key = $unread_only ? 'UNSEEN' : 'MESSAGES';
foreach (($status['untagged'] ?? []) as $status_line) {
if (preg_match('/STATUS [^(]*\(' .
preg_quote($key, '/') .
'\s+(\d+)/', $status_line, $status_match)) {
return (int) $status_match[1];
}
}
return 0;
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function setFlagBulk($folder, $uids, $flag, $value)
{
if (empty($uids)) {
return;
}
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$op = $value ? '+FLAGS' : '-FLAGS';
$uid_csv = implode(',', array_map('intval', $uids));
$this->component->userMailLog('imap-bulk-flag',
['folder' => $folder, 'count' => count($uids),
'op' => $op, 'flag' => $flag]);
$store = $client->send("UID STORE $uid_csv $op " .
"($flag)");
if ($store['status'] !== 'OK') {
$this->component->userMailLog(
'imap-bulk-flag-fail',
['folder' => $folder, 'count' => count($uids),
'detail' => $store['detail'] ?? '']);
throw new \Exception("UID STORE $op $flag " .
"bulk failed: " . ($store['detail'] ?? ''));
}
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function moveMessagesBulk($folder, $uids, $destination)
{
if (empty($uids)) {
return;
}
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$uid_csv = implode(',', array_map('intval', $uids));
$copy = $client->send("UID COPY $uid_csv \"" .
self::imapQuote($destination) . '"');
if ($copy['status'] !== 'OK') {
throw new \Exception("UID COPY to " .
"$destination failed: " .
($copy['detail'] ?? ''));
}
$store = $client->send("UID STORE $uid_csv +FLAGS " .
"(\\Deleted)");
if ($store['status'] !== 'OK') {
throw new \Exception("UID STORE +Deleted " .
"bulk failed: " .
($store['detail'] ?? ''));
}
/* prefer UID EXPUNGE (UIDPLUS, RFC 4315) so we only
purge the specific UIDs we just flagged; if the
server rejects it (no UIDPLUS), fall back to
plain EXPUNGE which also purges any other
pre-existing \\Deleted-flagged messages in the
source folder. */
$expunge = $client->send("UID EXPUNGE $uid_csv");
if ($expunge['status'] !== 'OK') {
$expunge = $client->send('EXPUNGE');
if ($expunge['status'] !== 'OK') {
throw new \Exception("EXPUNGE bulk " .
"failed: " .
($expunge['detail'] ?? ''));
}
}
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function deleteMessagesBulk($folder, $uids)
{
if (empty($uids)) {
return;
}
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$trash = $this->findSpecialFolder($client, '\\Trash',
['Trash', 'Deleted Items', '[Gmail]/Trash',
'Bin']);
if ($trash === null) {
throw new \Exception(tl(
'mail_backend_no_trash_folder'));
}
if (strcasecmp($folder, $trash) === 0) {
/* delete-from-Trash: skip the COPY, just mark the
selection deleted and expunge it in place. */
$this->purgeMessagesBulk($folder, $uids);
return;
}
$this->moveMessagesBulk($folder, $uids, $trash);
} catch (\Exception $exception) {
if ($exception instanceof MailBackendException) {
throw $exception;
}
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function purgeMessagesBulk($folder, $uids)
{
if (empty($uids)) {
return;
}
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$uid_csv = implode(',', array_map('intval', $uids));
$store = $client->send("UID STORE $uid_csv " .
"+FLAGS (\\Deleted)");
if ($store['status'] !== 'OK') {
throw new \Exception("UID STORE +Deleted " .
"bulk failed: " . ($store['detail'] ?? ''));
}
$expunge = $client->send("UID EXPUNGE $uid_csv");
if ($expunge['status'] !== 'OK') {
$expunge = $client->send('EXPUNGE');
if ($expunge['status'] !== 'OK') {
throw new \Exception("EXPUNGE bulk " .
"failed: " . ($expunge['detail'] ?? ''));
}
}
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function setFlag($folder, $uid, $flag, $value)
{
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$op = $value ? '+FLAGS' : '-FLAGS';
$this->component->userMailLog('imap-flag',
['folder' => $folder, 'uid' => $uid,
'op' => $op, 'flag' => $flag]);
$store = $client->send("UID STORE $uid $op ($flag)");
if ($store['status'] !== 'OK') {
$this->component->userMailLog('imap-flag-fail',
['folder' => $folder, 'uid' => $uid,
'detail' => $store['detail'] ?? '']);
throw new \Exception("UID STORE $op $flag " .
"failed: " . ($store['detail'] ?? ''));
}
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function appendMessage($folder, $bytes, $flags = [])
{
try {
$client = $this->client();
$flag_token = empty($flags) ? '' :
'(' . implode(' ', $flags) . ')';
$result = $client->appendMessage($folder, $flag_token,
$bytes);
if (!is_array($result) || empty($result['ok'])) {
throw new \Exception("APPEND failed: " .
($result['detail'] ?? ''));
}
/* UIDPLUS APPENDUID detection: response line of the
form "OK [APPENDUID uidvalidity uid] ..." */
$detail = $result['detail'] ?? '';
if (preg_match('/APPENDUID\s+\d+\s+(\d+)/i',
$detail, $match)) {
return (int) $match[1];
}
return null;
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function moveMessage($folder, $uid, $destination)
{
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$copy = $client->send('UID COPY ' . (int) $uid .
' "' . self::imapQuote($destination) . '"');
if ($copy['status'] !== 'OK') {
throw new \Exception("UID COPY failed: " .
($copy['detail'] ?? ''));
}
$store = $client->send("UID STORE $uid +FLAGS " .
"(\\Deleted)");
if ($store['status'] !== 'OK') {
throw new \Exception("UID STORE +Deleted " .
"failed: " . ($store['detail'] ?? ''));
}
$expunge = $client->send("UID EXPUNGE $uid");
if ($expunge['status'] !== 'OK') {
/* UID EXPUNGE requires UIDPLUS; fall back to
plain EXPUNGE which expunges everything
currently \\Deleted in the folder. We just
STORE'd \\Deleted on a single UID so the side
effect on other messages is bounded to anything
the user previously marked \\Deleted without
expunging. */
$client->send('EXPUNGE');
}
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function deleteMessage($folder, $uid)
{
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$trash = $this->findSpecialFolder($client, '\\Trash',
['Trash', 'Deleted Items', '[Gmail]/Trash',
'Bin']);
if ($trash === null) {
throw new \Exception(tl(
'mail_backend_no_trash_folder'));
}
if ($trash === $folder) {
$this->setFlag($folder, $uid, '\\Deleted', true);
$client->send("UID EXPUNGE $uid");
return;
}
$this->moveMessage($folder, $uid, $trash);
} catch (\Exception $exception) {
if ($exception instanceof MailBackendException) {
throw $exception;
}
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function purgeFolder($folder)
{
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$store = $client->send('UID STORE 1:* +FLAGS ' .
'(\\Deleted)');
if ($store['status'] !== 'OK') {
throw new \Exception("UID STORE failed: " .
($store['detail'] ?? ''));
}
$expunge = $client->send('EXPUNGE');
if ($expunge['status'] !== 'OK') {
throw new \Exception("EXPUNGE failed: " .
($expunge['detail'] ?? ''));
}
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function search($folder, $query)
{
try {
$client = $this->client();
$this->selectFolder($client, $folder);
$escaped = '"' . self::imapQuote($query) . '"';
$cmd = "UID SEARCH OR OR SUBJECT $escaped FROM " .
"$escaped TO $escaped";
$result = $client->send($cmd);
if ($result['status'] !== 'OK') {
throw new \Exception("SEARCH failed: " .
($result['detail'] ?? ''));
}
return $this->parseSearchUids(
$result['untagged'] ?? []);
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* {@inheritdoc}
*/
public function close()
{
if ($this->client !== null) {
try {
$this->client->close();
} catch (\Exception $exception) {
/* socket may already be dead; nothing to do. */
}
$this->client = null;
$this->selected_folder = null;
}
}
/**
* Lazily opens the IMAP client, runs LOGIN, and caches the
* client for reuse by subsequent method calls. Throws on
* connect failure so callers do not have to special-case the
* first vs subsequent call.
*
* @return object the open, logged-in IMAP client
*/
protected function client()
{
if ($this->client !== null) {
return $this->client;
}
$client = $this->openClient();
$this->imapLogin($client);
$this->client = $client;
/* Register the reconnect callback ImapClient::send() will
invoke on a connection-loss EOF. The handler opens a
fresh TCP/TLS/LOGIN session through the same code path
the initial open used (so retries inherit the account's
TLS mode, allow-self-signed setting, and credentials),
then transplants its socket into the dead ImapClient
via adoptSocketFrom() so the caller's still-held
reference becomes usable for the retry.
A freshly-logged-in session has no SELECT state, so if
a folder was selected before the drop we re-issue that
SELECT on the new socket before returning. This makes
the reconnect transparent to callers that issue raw
folder-scoped commands directly on the client (the
clone job's large-message ratchet loops on UID FETCH
BODY.PEEK[<offset.size>] without going back through
selectFolder), which would otherwise hit the upstream
"No mailbox selected" error on the command immediately
after a mid-stream reconnect. Callers that go through
selectFolder() are unaffected either way thanks to its
idempotence. If the re-SELECT itself fails the handler
reports the reconnect as failed so send() surfaces the
EOF rather than silently continuing folderless. The
handler returns true on a successful reconnect, false
otherwise; false means send() reports the EOF up to its
caller and the caller decides how to surface it. */
$client->setReconnectHandler(function ($dead)
use ($client) {
$prior_folder = $this->selected_folder;
try {
$fresh = $this->openClient();
$this->imapLogin($fresh);
} catch (\Exception $exception) {
$this->component->userMailLog(
'imap-reconnect-fail',
['err' => $exception->getMessage()]);
return false;
}
$dead->adoptSocketFrom($fresh);
$this->selected_folder = null;
if ($prior_folder !== null) {
$reselect = $dead->send('SELECT "' .
self::imapQuote($prior_folder) . '"');
if ($reselect['status'] !== 'OK') {
$this->component->userMailLog(
'imap-reconnect-fail',
['err' => 'reselect ' . $prior_folder .
' failed: ' .
($reselect['detail'] ?? '')]);
return false;
}
$this->selected_folder = $prior_folder;
}
$this->component->userMailLog('imap-reconnect-ok',
['folder' => $prior_folder ?? '']);
return true;
});
return $client;
}
/**
* Returns the lazily-opened, logged-in IMAP client this
* backend uses internally, the same instance the protected
* client() method returns. This is public so the mail clone job
* can drive the connection directly: it issues its own folder
* walks and large-message ratchet FETCHes on the raw client
* rather than going through the higher-level backend methods.
*
* @return object the open, logged-in IMAP client
*/
public function imapClient()
{
return $this->client();
}
/**
* Records which folder is currently SELECT'd on this
* backend's client. Callers that issue a raw SELECT directly
* on imapClient() (rather than going through selectFolder)
* must call this so the reconnect handler knows which folder
* to re-SELECT if the socket drops mid-stream. The clone
* job's folder walk does this: it issues its own SELECT to
* capture UIDVALIDITY from the untagged response, which
* selectFolder() does not expose, then notes the folder here.
*
* @param string $folder folder name now SELECT'd, or null to
* clear the tracked state
*/
public function noteSelectedFolder($folder)
{
$this->selected_folder = $folder;
}
/**
* Issues SELECT $folder unless the cached client is already
* SELECT'd on the same folder. Updates the tracked selected
* folder on success so the next call within the same folder
* skips the round-trip.
*
* @param object $client the open IMAP client
* @param string $folder folder to SELECT
* @throws \Exception when SELECT returns non-OK
*/
protected function selectFolder($client, $folder)
{
if ($this->selected_folder === $folder) {
return;
}
$select = $client->send('SELECT "' .
self::imapQuote($folder) . '"');
if ($select['status'] !== 'OK') {
$this->selected_folder = null;
throw new \Exception("SELECT $folder failed: " .
($select['detail'] ?? ''));
}
$this->selected_folder = $folder;
}
/**
* Common code path for create/rename/delete folder ops.
*
* @param string $op one of 'create', 'rename', 'delete'
* @param string|null $source existing folder name (rename,
* delete) or null (create)
* @param string|null $target new name (create, rename) or
* null (delete)
*/
protected function folderOp($op, $source, $target)
{
try {
$client = $this->client();
if ($op === 'create') {
$result = $client->send('CREATE "' .
self::imapQuote($target) . '"');
} else if ($op === 'rename') {
$result = $client->send('RENAME "' .
self::imapQuote($source) . '" "' .
self::imapQuote($target) . '"');
} else {
$result = $client->send('DELETE "' .
self::imapQuote($source) . '"');
}
if ($result['status'] !== 'OK') {
throw new \Exception("$op failed: " .
($result['detail'] ?? ''));
}
$this->selected_folder = null;
} catch (\Exception $exception) {
throw $this->translateException($exception);
}
}
/**
* Opens the IMAP TCP connection and TLS layer per the
* account's TLS_MODE setting (starttls, plain, or implicit
* imaps). Logs the open attempt and outcome.
*
* @return object an opened ImapClient ready for LOGIN
* @throws \Exception on connect failure
*/
protected function openClient()
{
$account = $this->account;
$host = $account["HOST"];
$port = (int) $account["PORT"];
$tls_mode = $account["TLS_MODE"];
$allow_self_signed = !empty($account["ALLOW_SELF_SIGNED"]);
$timeout = (int) C\MAIL_IMAP_CONNECT_TIMEOUT;
$started = microtime(true);
$this->component->userMailLog('imap-open',
['host' => $host, 'port' => $port,
'tls' => $tls_mode, 'timeout' => $timeout]);
try {
if ($tls_mode === 'starttls') {
$client = ImapClient::connectStartTls($host, $port,
$timeout, $allow_self_signed);
} else if ($tls_mode === 'plain') {
$client = new ImapClient($host, $port);
} else {
$client = ImapClient::connectImaps($host, $port,
$timeout, $allow_self_signed);
}
} catch (\Exception $exception) {
$this->component->userMailLog('imap-open-fail',
['host' => $host, 'port' => $port,
'tls' => $tls_mode,
'elapsed_ms' => (int) ((microtime(true) -
$started) * 1000),
'err' => $exception->getMessage()]);
throw $exception;
}
$this->component->userMailLog('imap-open-ok',
['host' => $host, 'port' => $port,
'elapsed_ms' => (int) ((microtime(true) -
$started) * 1000)]);
return $client;
}
/**
* Runs LOGIN on the open client with logging.
*
* @param object $client the open ImapClient
*/
protected function imapLogin($client)
{
$account = $this->account;
$username = $account["USERNAME"];
$started = microtime(true);
$this->component->userMailLog('imap-login',
['user' => $username]);
$login = $client->send('LOGIN "' .
self::imapQuote($username) . '" "' .
self::imapQuote($account["PASSWORD"]) . '"');
$elapsed_ms = (int) ((microtime(true) - $started) * 1000);
if ($login['status'] === 'EOF') {
/* server hung up before responding to LOGIN. Throw
with the ERROR_BAD_GREETING token so mapImapException
surfaces the localized "bad greeting" message
instead of a raw "LOGIN failed: socket not alive"
that mapImapException would otherwise pass through
to the generic-fallback branch. */
$this->component->userMailLog('imap-login-eof',
['user' => $username,
'elapsed_ms' => $elapsed_ms,
'detail' => $login['detail']]);
throw new \Exception(
ImapClient::ERROR_BAD_GREETING .
": connection closed during LOGIN");
}
if ($login['status'] !== 'OK') {
$this->component->userMailLog('imap-login-fail',
['user' => $username,
'elapsed_ms' => $elapsed_ms,
'detail' => $login['detail']]);
throw new \Exception("LOGIN failed: " .
$login['detail']);
}
$this->component->userMailLog('imap-login-ok',
['user' => $username,
'elapsed_ms' => $elapsed_ms]);
}
/**
* Issues LIST "" "*" with timing instrumentation.
*
* @param object $client open ImapClient
* @return array raw send() response
*/
protected function imapListAll($client)
{
$started = microtime(true);
$this->component->userMailLog('imap-list', []);
$list = $client->send('LIST "" "*"');
$elapsed_ms = (int) ((microtime(true) - $started) * 1000);
if ($list['status'] === 'OK') {
$this->component->userMailLog('imap-list-ok',
['count' => count($list['untagged'] ?? []),
'elapsed_ms' => $elapsed_ms]);
} else {
$this->component->userMailLog('imap-list-fail',
['elapsed_ms' => $elapsed_ms,
'detail' => $list['detail'] ?? '']);
}
return $list;
}
/**
* Walks the cached LIST output (re-fetching when needed) to
* resolve a SPECIAL-USE attribute to a folder name, with
* conventional-name fallbacks for servers that do not
* advertise SPECIAL-USE per RFC 6154.
*
* @param object $client open ImapClient
* @param string $special_use the special-use attribute, e.g.
* '\\Sent' or '\\Trash' (with leading backslash)
* @param array $name_fallbacks conventional names to try
* when no folder has the requested SPECIAL-USE
* @return string|null the folder name or null when nothing
* matches
*/
protected function findSpecialFolder($client, $special_use,
$name_fallbacks)
{
$list = $this->imapListAll($client);
if ($list['status'] !== 'OK') {
return null;
}
$folders = ImapFolderListParser::parse($list['untagged']);
foreach ($folders as $folder) {
if (strcasecmp($folder['SPECIAL_USE'] ?? '',
$special_use) === 0) {
return $folder['NAME'];
}
}
foreach ($name_fallbacks as $candidate) {
foreach ($folders as $folder) {
if (strcasecmp($folder['NAME'], $candidate) === 0) {
return $folder['NAME'];
}
}
}
return null;
}
/**
* Parses UID SEARCH or SEARCH ALL untagged output into a list
* of integer UIDs.
*
* @param array $untagged untagged response lines
* @return array list of ints
*/
protected function parseSearchUids($untagged)
{
$uids = [];
foreach ($untagged as $line) {
if (preg_match('/^\* SEARCH(.*)$/', $line, $match)) {
$parts = preg_split('/\s+/',
trim($match[1]));
foreach ($parts as $part) {
if (ctype_digit($part)) {
$uids[] = (int) $part;
}
}
}
}
return $uids;
}
/**
* Turns a FETCH reply's untagged lines into message rows keyed
* by sequence number, the shape the sortable inbox view and its
* scroll-by-position pagination expect. Each row carries the
* sequence number, the UID, the raw flags plus the read,
* answered, and flagged states read from them, and the date,
* subject, sender address, and sender display name pulled from
* the message envelope.
*
* The subject, sender, and sender name are returned exactly as
* the remote server sent them, with no cleaning. They arrive
* off the wire and end up echoed into the inbox HTML, so the
* controller that receives these rows must clean them before
* they are shown; doing it here would put display concerns in
* a library class.
*
* @param array $untagged the untagged lines of a FETCH reply
* @return array message rows in the order the lines appeared,
* each with SEQ, UID, FLAGS, IS_UNREAD, IS_ANSWERED,
* IS_FLAGGED, DATE, DATE_TS, SUBJECT, FROM, and FROM_NAME;
* SUBJECT, FROM, and FROM_NAME are raw and need cleaning
*/
protected function parseSeqEnvelopes($untagged)
{
$messages = [];
foreach ($untagged as $line) {
if (!preg_match('/UID (\d+)/', $line, $uid_match)) {
continue;
}
$uid = (int) $uid_match[1];
$seq = 0;
if (preg_match('/^\* (\d+) FETCH/', $line, $seq_match)) {
$seq = (int) $seq_match[1];
}
$message = ["SEQ" => $seq, "UID" => $uid, "FLAGS" => "",
"IS_UNREAD" => true, "IS_ANSWERED" => false,
"IS_FLAGGED" => false, "DATE" => "", "SUBJECT" => "",
"FROM" => ""];
if (preg_match('/FLAGS \(([^)]*)\)/', $line,
$flag_match)) {
$message["FLAGS"] = $flag_match[1];
/* IMAP system flags are case-insensitive; match each
as a whole token so a future keyword whose name
starts with "Seen" (and so on) is not mistaken for
the system flag. */
foreach (preg_split('/\s+/', $flag_match[1])
as $token) {
if (strcasecmp($token, "\\Seen") === 0) {
$message["IS_UNREAD"] = false;
} else if (strcasecmp($token, "\\Answered")
=== 0) {
$message["IS_ANSWERED"] = true;
} else if (strcasecmp($token, "\\Flagged")
=== 0) {
$message["IS_FLAGGED"] = true;
}
}
}
$envelope = ImapEnvelopeParser::parse($line);
$message["DATE"] = $envelope["DATE"];
$message["DATE_TS"] = $envelope["DATE"] !== '' ?
((int) strtotime($envelope["DATE"])) : 0;
$message["SUBJECT"] = $envelope["SUBJECT"];
$message["FROM"] = $envelope["FROM"];
$message["FROM_NAME"] = ImapListing::extractFromName(
$envelope["FROM"]);
$messages[] = $message;
}
return $messages;
}
/**
* Fetches the envelopes for a list of sequence numbers while
* staying resilient to a server that struggles with large
* requests. It starts with a modest batch, doubles the batch on
* each success so a healthy server is soon fetching large runs
* at once, and halves the batch whenever one fails. A single
* message that fails even on its own (the server has a corrupt
* copy or keeps dropping it) is replaced with a placeholder row
* so one bad message cannot blank the whole inbox. The result is
* returned in the requested order with any unfetchable messages
* standing in as placeholders, so the order the person chose is
* preserved with no gaps.
*
* The batch-size rules and result ordering live in ImapListing
* so they can be checked without a live server; this method does
* the talking to the server and applies them.
*
* @param array $sequence_numbers the sequence numbers to fetch,
* in the order they should appear
* @param string $unreadable_subject the already-translated text
* to show as the subject of a message that could not be
* fetched
* @return array the message rows in the requested order, with a
* placeholder row in place of any that could not be fetched
*/
protected function adaptiveFetch($sequence_numbers,
$unreadable_subject)
{
$total = count($sequence_numbers);
$position = 0;
$batch = ImapListing::initialBatch($total);
$streak = 0;
$by_seq = [];
while ($position < $total) {
$candidate = array_slice($sequence_numbers, $position,
$batch);
try {
$batch_client = $this->client();
$batch_fetch = $batch_client->send("FETCH " .
implode(",", $candidate) .
" (UID FLAGS ENVELOPE)");
if (($batch_fetch['status'] ?? '') !== 'OK') {
$outcome = ['ok' => false, 'by_seq' => []];
} else {
$batch_by_seq = [];
foreach ($this->parseSeqEnvelopes(
$batch_fetch['untagged']) as $batch_row) {
$batch_by_seq[$batch_row['SEQ']] =
$batch_row;
}
$outcome = ['ok' => true,
'by_seq' => $batch_by_seq];
}
} catch (\Exception $batch_exception) {
$outcome = ['ok' => false, 'by_seq' => []];
}
if ($outcome['ok']) {
foreach ($outcome['by_seq'] as $seq => $row) {
$by_seq[$seq] = $row;
}
$position += count($candidate);
$streak++;
$batch = ImapListing::growBatch($total - $position,
$streak);
continue;
}
if ($batch === 1) {
$bad_seq = $candidate[0];
$this->component->userMailLog(
'imap-fetch-batch-fail',
['seq' => $bad_seq, 'placeholder' => 1]);
$by_seq[$bad_seq] = ImapListing::placeholderRow(
$bad_seq, $unreadable_subject);
$position++;
$batch = 1;
$streak = 0;
continue;
}
$batch = ImapListing::shrinkBatch($batch);
$streak = 0;
}
return ImapListing::orderBySequence($by_seq,
$sequence_numbers);
}
/**
* Translates a raw \Exception thrown from the IMAP client
* (or this backend's own internal helpers) into a
* MailBackendException whose message is already localized
* via tl(). The translation table matches the legacy
* userMailImapErrorMessage helper in SocialComponent so user-
* visible error wording is consistent across the migration.
*
* @param \Exception $exception the raw exception
* @return MailBackendException ready to throw to the caller
*/
protected function translateException($exception)
{
$raw = $exception->getMessage();
$token = $raw;
$detail = "";
$colon = strpos($raw, ": ");
if ($colon !== false) {
$token = substr($raw, 0, $colon);
$detail = substr($raw, $colon + 2);
}
$map = [
ImapClient::ERROR_CERT_VERIFY =>
tl('social_component_mail_err_cert_verify'),
ImapClient::ERROR_CERT_HOSTNAME =>
tl('social_component_mail_err_cert_hostname'),
ImapClient::ERROR_CERT_EXPIRED =>
tl('social_component_mail_err_cert_expired'),
ImapClient::ERROR_SECURE_CONNECT =>
tl('social_component_mail_err_secure_connect'),
ImapClient::ERROR_CONNECTION_REFUSED =>
tl('social_component_mail_err_connection_refused'),
ImapClient::ERROR_TIMED_OUT =>
tl('social_component_mail_err_timed_out'),
ImapClient::ERROR_HOST_NOT_FOUND =>
tl('social_component_mail_err_host_not_found'),
ImapClient::ERROR_CONNECT_FAILED =>
tl('social_component_mail_err_connect_failed'),
ImapClient::ERROR_BAD_GREETING =>
tl('social_component_mail_err_bad_greeting'),
];
/* Connection-level failures: the socket dropped, the
server hung up, or it never answered. These are worth
retrying on a later run rather than treating as fatal,
so the clone job can resume instead of failing the whole
job. Cert errors are deliberately excluded -- they will
not fix themselves on retry. */
$transient_tokens = [
ImapClient::ERROR_SECURE_CONNECT => true,
ImapClient::ERROR_CONNECTION_REFUSED => true,
ImapClient::ERROR_TIMED_OUT => true,
ImapClient::ERROR_HOST_NOT_FOUND => true,
ImapClient::ERROR_CONNECT_FAILED => true,
ImapClient::ERROR_BAD_GREETING => true,
];
if (isset($map[$token])) {
$message = $map[$token];
if ($detail !== "") {
$message .= " ($detail)";
}
$translated = new MailBackendException($message, 0,
$exception);
if (isset($transient_tokens[$token])) {
$translated->setTransient(true);
}
return $translated;
}
/* A dropped socket mid-command surfaces as a raw
"... failed: read failed mid-response" (or a "socket not
alive" / EOF detail) that did not carry one of the mapped
connection tokens; still treat it as transient. */
$transient_markers = ['read failed mid-response',
'socket not alive', 'connection closed'];
$is_transient = false;
foreach ($transient_markers as $marker) {
if (stripos($raw, $marker) !== false) {
$is_transient = true;
break;
}
}
$generic = new MailBackendException(tl(
'social_component_mail_err_generic', $raw), 0,
$exception);
$generic->setTransient($is_transient);
return $generic;
}
/**
* Quotes a value for inclusion inside an IMAP-protocol
* double-quoted string per RFC 3501 sec 4.3: escapes
* embedded backslashes and double quotes by prepending a
* backslash, drops bare CR and LF characters since they
* cannot appear inside a quoted-string.
*
* @param string $value the raw value to quote
* @return string the quoted form, ready to embed inside
* surrounding double quotes
*/
public static function imapQuote($value)
{
$value = (string) $value;
$value = str_replace(["\\", '"'], ["\\\\", '\\"'], $value);
return str_replace(["\r", "\n"], '', $value);
}
}