From ebd5c30adfec4d3bd05968678dae7177c55bb66a Mon Sep 17 00:00:00 2001 From: Dennis Hermsmeier Date: Tue, 26 May 2026 12:33:25 +0200 Subject: [PATCH] feat: preflight check, OVERQUOTA fatal-stop, support notice - New preflight AJAX action with a checklist (connection, folder list, quota fit with a 15% safety margin) that gates the Start migration button. The estimated source size vs. PHP max_execution_time also surfaces as an informational warning row when the ratio looks tight. - A runtime [OVERQUOTA] response on APPEND now stops the sync immediately via RoundcubeImapSyncQuotaExceededException + result.quotaExceeded, instead of producing one error per source message. The UI shows a localized "target mailbox is full" message instead of the raw IMAP response. - noticeretry now surfaces the concrete max_execution_time value of the installation; the unlimited case keeps the previous wording. - Adds a support notice with a GitHub Issues link below the migration form. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 16 +- README.md | 21 ++- imapsync.js | 211 ++++++++++++++++++++++- imapsync.php | 108 +++++++++++- lib/RoundcubeImapSyncClient.php | 64 ++++++- lib/RoundcubeImapSyncEngine.php | 68 ++++++++ lib/RoundcubeImapSyncException.php | 4 + lib/RoundcubeImapSyncPreflightResult.php | 33 ++++ lib/RoundcubeImapSyncResult.php | 2 + localization/de_DE.inc | 16 ++ localization/en_US.inc | 16 ++ skins/elastic/imapsync.css | 44 +++++ skins/elastic/templates/imapsync.html | 4 + tests/Fakes/FakeImapClient.php | 41 +++++ tests/ImapSyncEngineTest.php | 88 ++++++++++ 15 files changed, 715 insertions(+), 21 deletions(-) create mode 100644 lib/RoundcubeImapSyncPreflightResult.php diff --git a/AGENTS.md b/AGENTS.md index 58b60a6..dabb971 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,9 +62,10 @@ future worker mode — must use an out-of-session store (file, DB, Roundcube cac ├── config.inc.php.dist # default plugin config, copied by user to config.inc.php ├── lib/ │ ├── RoundcubeImapSyncClient.php # IMAP client interface + rcube_imap_generic adapter -│ ├── RoundcubeImapSyncEngine.php # core sync logic (folder walk, UID dedup, append) -│ ├── RoundcubeImapSyncException.php # plugin-specific RuntimeException subclass +│ ├── RoundcubeImapSyncEngine.php # core sync logic (folder walk, UID dedup, append) + preflight() +│ ├── RoundcubeImapSyncException.php # plugin-specific RuntimeException subclass + QuotaExceeded subclass │ ├── RoundcubeImapSyncJob.php # parameter object: source creds + options +│ ├── RoundcubeImapSyncPreflightResult.php # preflight payload: connection/folders/quota checks │ └── RoundcubeImapSyncResult.php # tallies (folders synced, messages copied, skipped, errors) ├── localization/ │ ├── en_US.inc # source of truth @@ -164,8 +165,8 @@ global rules win. expects the plugin's main class to be globally named after the plugin directory (here: `class imapsync extends rcube_plugin`). Helper classes under `lib/` follow the same convention: globally named with a `RoundcubeImapSync` prefix - (`RoundcubeImapSyncEngine`, `RoundcubeImapSyncJob`, `RoundcubeImapSyncResult`), autoloaded - via the `composer.json` `classmap`. + (`RoundcubeImapSyncEngine`, `RoundcubeImapSyncJob`, `RoundcubeImapSyncResult`, + `RoundcubeImapSyncPreflightResult`), autoloaded via the `composer.json` `classmap`. - **No comments unless intent is non-obvious.** Do not restate what code does. Comment a workaround, a hidden invariant, or a non-obvious choice. PHPDoc only on public class API where types are not expressible inline (e.g. arrays with known shapes). @@ -418,8 +419,11 @@ skips itself cleanly when Docker is not available. correct but slow. - No support for the IMAP `CONDSTORE` / `QRESYNC` extensions yet. They would make incremental sync cheap. -- No quota-aware throttling. If the destination account is near quota, the sync will produce - a flood of `OVERQUOTA` errors instead of stopping gracefully. +- Preflight estimates source size against destination free quota with a 15% safety margin, and + the engine treats a runtime `[OVERQUOTA]` response as fatal (stops immediately, sets + `result.quotaExceeded`). Edge cases that remain: the destination fills up from external + activity between preflight and run, or the server has no `QUOTA` extension (preflight then + reports "unknown" and lets the user start anyway). - Skin support: ships an `elastic` skin only. The legacy `classic` skin is not targeted. - A separate integration suite exists for the real `RoundcubeImapSyncGenericClient`; it runs against Dovecot containers via `composer test:integration`. diff --git a/README.md b/README.md index 9f4ee01..038aa43 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,18 @@ Reload Roundcube. A new **Email migration** entry appears under Settings. 1. Open Roundcube, log in to the account that should **receive** the mail. 2. Go to **Settings → Email migration**. 3. Enter the **source** server's host, port, encryption, username and password. -4. Click **Start migration**. The browser shows a "loading" indicator until the run finishes. -5. When the run completes, the page shows a summary (folders migrated, messages copied, +4. Click **Verify migration**. A checklist appears showing whether the source is reachable, + how many folders were found, and whether the destination has enough free space (estimated + from source size with a 15% safety margin). The **Start migration** button is disabled + until this check passes (or quota information is unavailable, in which case it unlocks + anyway with a warning). +5. Click **Start migration**. The browser shows a "loading" indicator until the run finishes. +6. When the run completes, the page shows a summary (folders migrated, messages copied, messages skipped, errors). -6. Open your inbox — the migrated folders and messages are there. +7. Open your inbox — the migrated folders and messages are there. + +If the destination mailbox is full mid-run, the sync stops immediately with a clear +"target mailbox is full" message instead of producing one error per source message. ### Re-running against the same source @@ -226,8 +234,11 @@ context picks it up. copied messages are skipped fast, but the folder walk still happens. - **No `CONDSTORE` / `QRESYNC`.** Incremental syncs are correct but not as cheap as they could be on servers that support these IMAP extensions. -- **No quota-aware throttling.** If the destination account is close to quota, you will see a - burst of `OVERQUOTA` errors instead of a clean stop. +- **Quota handling is best-effort.** The "Verify migration" check estimates source size against + destination free quota with a 15% safety margin, and a runtime `[OVERQUOTA]` response stops + the sync immediately with a clear message. The verify check cannot help if the destination + server has no `QUOTA` extension (it then reports "unknown") or if the destination fills up + from external activity between verify and start. - **Elastic skin only.** The legacy `classic` skin is not targeted. --- diff --git a/imapsync.js b/imapsync.js index 08a751c..e99a087 100644 --- a/imapsync.js +++ b/imapsync.js @@ -2,8 +2,11 @@ if (window.rcmail) { rcmail.addEventListener('init', function () { var form = document.getElementById('imapsync-form'), submitButton = document.getElementById('imapsync-submit'), + verifyButton = document.getElementById('imapsync-verify'), cancelButton = document.getElementById('imapsync-cancel'), - startRequest = null; + startRequest = null, + preflightRequest = null, + lastPreflightReady = false; function label(name) { return rcmail.gettext(name, 'imapsync'); @@ -11,8 +14,12 @@ if (window.rcmail) { function setFormDisabled(disabled) { if (submitButton) { - submitButton.disabled = disabled; - submitButton.classList.toggle('disabled', disabled); + submitButton.disabled = disabled || !lastPreflightReady; + submitButton.classList.toggle('disabled', submitButton.disabled); + } + + if (verifyButton) { + verifyButton.disabled = disabled; } if (cancelButton) { @@ -20,6 +27,24 @@ if (window.rcmail) { } } + function disableSubmit() { + lastPreflightReady = false; + + if (submitButton) { + submitButton.disabled = true; + submitButton.classList.add('disabled'); + } + } + + function setSubmitFromPreflight(result) { + lastPreflightReady = !!result.readyToStart; + + if (submitButton) { + submitButton.disabled = !lastPreflightReady; + submitButton.classList.toggle('disabled', !lastPreflightReady); + } + } + function renderProgress(progress) { var region = document.getElementById('imapsync-progress'), list = document.getElementById('imapsync-progress-list'); @@ -67,13 +92,138 @@ if (window.rcmail) { content.appendChild(table); if (result.fatalError || errors.length) { - content.appendChild(errorList(result.fatalError, errors)); + content.appendChild(errorList(result.fatalError, errors, result.quotaExceeded)); } region.hidden = false; rcmail.display_message(label(messageKey), result.fatalError || errors.length ? 'warning' : 'confirmation'); } + function renderPreflight(result) { + var region = document.getElementById('imapsync-preflight'), + content = document.getElementById('imapsync-preflight-content'), + list = document.createElement('ul'), + freeBytes = result.destinationLimit !== null && result.destinationLimit !== undefined + && result.destinationUsed !== null && result.destinationUsed !== undefined + ? Math.max(0, result.destinationLimit - result.destinationUsed) + : null, + folderDetail = fillPlaceholders(label('preflightfoldersdetail'), { + count: result.folderCount || 0 + }), + quotaDetail; + + if (!region || !content) { + return; + } + + if (result.quotaChecked && result.quotaFits) { + quotaDetail = fillPlaceholders(label('preflightquotaokdetail'), { + source: formatBytes(result.sourceBytes), + free: formatBytes(freeBytes) + }); + } else if (result.quotaChecked) { + quotaDetail = fillPlaceholders(label('preflightquotafaildetail'), { + source: formatBytes(result.sourceBytes), + free: formatBytes(freeBytes) + }); + } else { + quotaDetail = label('preflightquotaunknowndetail'); + } + + list.className = 'imapsync-checklist'; + list.appendChild(checkRow( + result.connectionOk ? 'ok' : 'fail', + label('preflightcheckconnection'), + result.connectionOk ? '' : result.connectionError + )); + list.appendChild(checkRow( + result.foldersOk && result.folderCount > 0 ? 'ok' : 'fail', + label('preflightcheckfolders'), + folderDetail + )); + list.appendChild(checkRow( + result.quotaChecked ? (result.quotaFits ? 'ok' : 'fail') : 'warn', + label('preflightcheckquota'), + quotaDetail + )); + if (result.timeoutRisk) { + list.appendChild(checkRow( + 'warn', + '', + fillPlaceholders(label('preflighttimeoutwarn'), { + source: formatBytes(result.sourceBytes), + seconds: result.maxExecutionTime + }) + )); + } + + content.innerHTML = ''; + content.appendChild(list); + content.appendChild(preflightHint(result.readyToStart)); + region.hidden = false; + setSubmitFromPreflight(result); + } + + function checkRow(status, title, detail) { + var item = document.createElement('li'), + icon = document.createElement('span'), + text = document.createElement('span'), + titleElement = document.createElement('span'), + detailElement = document.createElement('span'), + iconText = status === 'ok' ? '✓' : (status === 'warn' ? '⚠' : '✗'); + + item.className = 'imapsync-check-row'; + icon.className = 'imapsync-check-icon imapsync-check-' + status; + icon.textContent = iconText; + titleElement.textContent = title; + text.appendChild(titleElement); + + if (detail) { + detailElement.className = 'imapsync-check-detail'; + detailElement.textContent = detail; + text.appendChild(detailElement); + } + + item.appendChild(icon); + item.appendChild(text); + + return item; + } + + function preflightHint(readyToStart) { + var hint = document.createElement('p'); + + hint.className = 'imapsync-preflight-hint'; + hint.textContent = label(readyToStart ? 'preflightreadyhint' : 'preflightnotreadyhint'); + + return hint; + } + + function formatBytes(bytes) { + var units = ['B', 'KB', 'MB', 'GB', 'TB'], + i = 0, + n = Math.max(0, bytes); + + if (bytes === null || bytes === undefined) { + return '?'; + } + + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + + return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i]; + } + + function fillPlaceholders(text, values) { + Object.keys(values).forEach(function (key) { + text = text.split('%' + key + '%').join(values[key]); + }); + + return text; + } + function summaryRow(title, value, table) { var row = document.createElement('tr'), header = document.createElement('th'), @@ -86,11 +236,13 @@ if (window.rcmail) { table.appendChild(row); } - function errorList(fatalError, errors) { + function errorList(fatalError, errors, quotaExceeded) { var list = document.createElement('ul'); list.className = 'imapsync-errors'; - if (fatalError) { + if (quotaExceeded) { + list.appendChild(errorItem(label('errorquota'))); + } else if (fatalError) { list.appendChild(errorItem(fatalError)); } @@ -121,8 +273,19 @@ if (window.rcmail) { } }); + rcmail.addEventListener('plugin.imapsync_preflight', function (result) { + preflightRequest = null; + + if (verifyButton) { + verifyButton.disabled = false; + } + + renderPreflight(result); + }); + rcmail.addEventListener('plugin.imapsync_error', function (message) { startRequest = null; + preflightRequest = null; setFormDisabled(false); rcmail.display_message(message || label('errorvalidation'), 'error'); }); @@ -138,11 +301,38 @@ if (window.rcmail) { }); } + if (verifyButton && form) { + verifyButton.addEventListener('click', function () { + var lock, + resultRegion = document.getElementById('imapsync-result'), + progressRegion = document.getElementById('imapsync-progress'); + + if (resultRegion) { + resultRegion.hidden = true; + } + + if (progressRegion) { + progressRegion.hidden = true; + } + + disableSubmit(); + verifyButton.disabled = true; + lock = rcmail.set_busy(true, 'loading'); + preflightRequest = rcmail.http_post('plugin.imapsync.preflight', $(form).serialize(), lock); + }); + } + if (form) { form.addEventListener('submit', function (event) { var lock; event.preventDefault(); + if (!lastPreflightReady) { + disableSubmit(); + + return; + } + if (!window.confirm(label('confirmstart'))) { return; } @@ -153,5 +343,14 @@ if (window.rcmail) { startRequest = rcmail.http_post('plugin.imapsync.start', $(form).serialize(), lock); }); } + + ['imapsync-host', 'imapsync-port', 'imapsync-encryption', 'imapsync-user', 'imapsync-password'].forEach(function (id) { + var element = document.getElementById(id); + + if (element) { + element.addEventListener('input', disableSubmit); + element.addEventListener('change', disableSubmit); + } + }); }); } diff --git a/imapsync.php b/imapsync.php index 8052f6e..0e9da90 100644 --- a/imapsync.php +++ b/imapsync.php @@ -3,11 +3,14 @@ require_once __DIR__ . '/lib/RoundcubeImapSyncException.php'; require_once __DIR__ . '/lib/RoundcubeImapSyncClient.php'; require_once __DIR__ . '/lib/RoundcubeImapSyncJob.php'; +require_once __DIR__ . '/lib/RoundcubeImapSyncPreflightResult.php'; require_once __DIR__ . '/lib/RoundcubeImapSyncResult.php'; require_once __DIR__ . '/lib/RoundcubeImapSyncEngine.php'; class imapsync extends rcube_plugin { + private const SUPPORT_ISSUES_URL = 'https://github.com/mittwald/Roundcube-IMAPsync/issues'; + public $task = 'settings'; private rcmail $rc; @@ -20,6 +23,7 @@ public function init(): void $this->add_texts('localization/', true); $this->add_hook('settings_actions', [$this, 'settings_actions']); $this->register_action('plugin.imapsync', [$this, 'action_form']); + $this->register_action('plugin.imapsync.preflight', [$this, 'action_preflight']); $this->register_action('plugin.imapsync.start', [$this, 'action_start']); $this->register_action('plugin.imapsync.status', [$this, 'action_status']); } @@ -53,6 +57,17 @@ public function action_form(): void 'imapsync.donesuccess', 'imapsync.donewitherrors', 'imapsync.errorvalidation', + 'imapsync.errorquota', + 'imapsync.preflightcheckconnection', + 'imapsync.preflightcheckfolders', + 'imapsync.preflightcheckquota', + 'imapsync.preflightfoldersdetail', + 'imapsync.preflightquotaokdetail', + 'imapsync.preflightquotafaildetail', + 'imapsync.preflightquotaunknowndetail', + 'imapsync.preflighttimeoutwarn', + 'imapsync.preflightreadyhint', + 'imapsync.preflightnotreadyhint', ); $this->rc->output->set_env('imapsync_allow_insecure', $this->allowInsecure()); $this->rc->output->set_pagetitle($this->gettext('pagetitle')); @@ -111,10 +126,16 @@ public function render_form(array $attrib): string $this->addFormRow($table, 'imapsync-user', 'sourceuser', $user->show()); $this->addFormRow($table, 'imapsync-password', 'sourcepassword', $password->show()); + $verifyButton = html::tag('button', [ + 'type' => 'button', + 'id' => 'imapsync-verify', + 'class' => 'button mainaction', + ], rcube::Q($this->gettext('preflightbutton'))); $button = html::tag('button', [ 'type' => 'submit', 'id' => 'imapsync-submit', - 'class' => 'button mainaction submit', + 'class' => 'button mainaction submit disabled', + 'disabled' => 'disabled', ], rcube::Q($this->gettext('startsync'))); $cancelButton = html::tag('button', [ 'type' => 'button', @@ -127,24 +148,35 @@ public function render_form(array $attrib): string 'name' => 'imapsync-form', 'method' => 'post', 'action' => './?_task=settings&_action=plugin.imapsync.start', - ], $table->show() . html::p(['class' => 'formbuttons footerleft'], $button . ' ' . $cancelButton)); + ], $table->show() . html::p(['class' => 'formbuttons footerleft'], $verifyButton . ' ' . $button . ' ' . $cancelButton)); $notice = html::div(['class' => 'boxinformation imapsync-notice'], html::tag('strong', ['class' => 'imapsync-notice-title'], rcube::Q($this->gettext('noticetitle'))) . html::tag('ul', ['class' => 'imapsync-notice-list'], html::tag('li', [], rcube::Q($this->gettext('noticepreserves'))) . html::tag('li', [], rcube::Q($this->gettext('noticesynchronous'))) - . html::tag('li', [], rcube::Q($this->gettext('noticeretry'))) + . html::tag('li', [], rcube::Q($this->retryNoticeText())) . html::tag('li', [], rcube::Q($this->gettext('noticeduration'))) ) ); + $supportLink = html::a([ + 'href' => self::SUPPORT_ISSUES_URL, + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ], rcube::Q($this->gettext('supportnoticelink'))); + $supportNotice = html::p( + ['class' => 'imapsync-support'], + strtr(rcube::Q($this->gettext('supportnotice')), ['%link%' => $supportLink]) + ); + return html::div(['id' => 'prefs-title', 'class' => 'boxtitle'], rcube::Q($this->gettext('pagetitle'))) . html::div(['class' => 'box formcontainer scroller'], html::div(['class' => 'boxcontent formcontent'], html::p(['class' => 'imapsync-intro'], rcube::Q($this->gettext('intro'))) . $notice . $form + . $supportNotice ) ); } @@ -193,6 +225,43 @@ public function action_start(): void $this->rc->output->send(); } + public function action_preflight(): void + { + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + try { + $job = $this->createJobFromRequest(); + $source = new RoundcubeImapSyncGenericClient(new rcube_imap_generic()); + $destination = $this->createDestinationClient(); + $engine = new RoundcubeImapSyncEngine($source, $destination); + + $result = $engine->preflight($job); + } catch (InvalidArgumentException $validationException) { + $message = $validationException->getMessage() !== '' + ? $validationException->getMessage() + : $this->gettext('errorvalidation'); + $this->rc->output->command('plugin.imapsync_error', $message); + $this->rc->output->send(); + + return; + } catch (RoundcubeImapSyncException $syncException) { + $result = new RoundcubeImapSyncPreflightResult(); + $result->connectionError = $syncException->getMessage(); + } + + $payload = $result->toArray(); + $payload['maxExecutionTime'] = $this->maxExecutionTime(); + $payload['timeoutRisk'] = $this->isTimeoutRisky( + $payload['sourceBytes'] ?? null, + $payload['maxExecutionTime'] + ); + + $this->rc->output->command('plugin.imapsync_preflight', $payload); + $this->rc->output->send(); + } + public function action_status(): void { // Retained for the planned worker-mode iteration. In the synchronous @@ -288,4 +357,37 @@ private function allowInsecure(): bool { return (bool) $this->rc->config->get('imapsync_allow_insecure', false); } + + private function retryNoticeText(): string + { + $maxExecutionTime = $this->maxExecutionTime(); + if ($maxExecutionTime <= 0) { + return $this->gettext('noticeretry'); + } + + return strtr($this->gettext('noticeretrylimit'), ['%seconds%' => (string) $maxExecutionTime]); + } + + private function maxExecutionTime(): int + { + $value = (int) ini_get('max_execution_time'); + + return max(0, $value); + } + + private function isTimeoutRisky(?int $sourceBytes, int $maxExecutionTime): bool + { + if ($sourceBytes === null || $sourceBytes <= 0) { + return false; + } + + if ($maxExecutionTime <= 0) { + return false; + } + + $bytesPerSecond = $sourceBytes / $maxExecutionTime; + $threshold = (100 * 1024 * 1024) / 60; + + return $bytesPerSecond > $threshold; + } } diff --git a/lib/RoundcubeImapSyncClient.php b/lib/RoundcubeImapSyncClient.php index 24ba5fe..f0e23a6 100644 --- a/lib/RoundcubeImapSyncClient.php +++ b/lib/RoundcubeImapSyncClient.php @@ -14,6 +14,12 @@ public function createFolder(string $folder): bool; public function selectFolder(string $folder): int; + public function getFolderSize(string $folder): int; + + public function getQuota(string $folder): ?array; + + public function supportsStatusSize(): bool; + public function fetchMessageIdentities(string $folder): array; public function fetchMessageRaw(string $folder, int $uid): ?array; @@ -23,6 +29,8 @@ public function appendMessage(string $folder, string $rawMessage, array $flags, class RoundcubeImapSyncGenericClient implements RoundcubeImapSyncClient { + private ?bool $statusSizeSupported = null; + public function __construct(private readonly rcube_imap_generic $imap) { } @@ -73,6 +81,55 @@ public function selectFolder(string $folder): int return (int) ($status['MESSAGES'] ?? 0); } + public function getFolderSize(string $folder): int + { + if ($this->supportsStatusSize()) { + $status = $this->imap->status($folder, ['SIZE']); + if (is_array($status) && array_key_exists('SIZE', $status)) { + return (int) $status['SIZE']; + } + } + + $totalMessages = $this->selectFolder($folder); + if ($totalMessages === 0) { + return 0; + } + + $messages = $this->imap->fetch($folder, '1:*', false, ['UID', 'RFC822.SIZE']); + if ($messages === false) { + throw new RoundcubeImapSyncException($this->getErrorMessage("Could not fetch sizes for {$folder}.")); + } + + $totalSize = 0; + foreach ($messages as $message) { + $totalSize += (int) ($message->size ?? 0); + } + + return $totalSize; + } + + public function getQuota(string $folder): ?array + { + $quota = $this->imap->getQuota($folder); + if ($quota === false) { + return null; + } + + return [ + 'used' => (int) $quota['used'] * 1024, + 'total' => (int) $quota['total'] * 1024, + ]; + } + + public function supportsStatusSize(): bool + { + if ($this->statusSizeSupported === null) { + $this->statusSizeSupported = (bool) $this->imap->getCapability('STATUS=SIZE'); + } + + return $this->statusSizeSupported; + } + public function fetchMessageIdentities(string $folder): array { $totalMessages = $this->selectFolder($folder); @@ -127,7 +184,12 @@ public function appendMessage(string $folder, string $rawMessage, array $flags, $message = $rawMessage; $result = $this->imap->append($folder, $message, $flags, $internalDate, false); if (!$result && $this->imap->error) { - throw new RoundcubeImapSyncException($this->getErrorMessage("Could not append message to {$folder}.")); + $errorMessage = $this->getErrorMessage("Could not append message to {$folder}."); + if (stripos($errorMessage, '[OVERQUOTA]') !== false) { + throw new RoundcubeImapSyncQuotaExceededException($errorMessage); + } + + throw new RoundcubeImapSyncException($errorMessage); } return $result; diff --git a/lib/RoundcubeImapSyncEngine.php b/lib/RoundcubeImapSyncEngine.php index fb3d214..7a7655b 100644 --- a/lib/RoundcubeImapSyncEngine.php +++ b/lib/RoundcubeImapSyncEngine.php @@ -8,6 +8,7 @@ class RoundcubeImapSyncEngine { private const PROGRESS_BATCH_SIZE = 10; + private const PREFLIGHT_SAFETY_MARGIN = 1.15; public function __construct( private RoundcubeImapSyncClient $source, @@ -42,6 +43,9 @@ public function run(RoundcubeImapSyncJob $job, callable $progress): RoundcubeIma $this->syncFolder($sourceFolder, $destinationFolder, $progress, $result); } + } catch (RoundcubeImapSyncQuotaExceededException $quotaException) { + $result->fatalError = $quotaException->getMessage(); + $result->quotaExceeded = true; } catch (RoundcubeImapSyncException $syncException) { $result->fatalError = $syncException->getMessage(); } finally { @@ -52,6 +56,68 @@ public function run(RoundcubeImapSyncJob $job, callable $progress): RoundcubeIma return $result; } + public function preflight(RoundcubeImapSyncJob $job): RoundcubeImapSyncPreflightResult + { + $result = new RoundcubeImapSyncPreflightResult(); + + try { + $this->source->connect( + $job->getHost(), + $job->getPort(), + $job->getSourceUser(), + $job->getSourcePassword(), + $this->buildConnectionOptions($job), + ); + $result->connectionOk = true; + } catch (RoundcubeImapSyncException $connectException) { + $result->connectionError = $connectException->getMessage(); + + return $result; + } + + try { + $folders = $this->filterFolders($this->source->listFolders(), $job->getSkipFolders()); + $result->folderCount = count($folders); + $result->foldersOk = true; + } catch (RoundcubeImapSyncException $folderException) { + $result->folderError = $folderException->getMessage(); + $this->source->close(); + + return $result; + } + + $sourceBytes = 0; + foreach ($folders as $folder) { + try { + $sourceBytes += $this->source->getFolderSize($folder); + } catch (RoundcubeImapSyncException $sizeException) { + // Per-folder size failures make the estimate approximate, not invalid. + } + } + $result->sourceBytes = $sourceBytes; + + try { + $quota = $this->destination->getQuota('INBOX'); + if (is_array($quota)) { + $result->quotaChecked = true; + $result->destinationUsed = (int) $quota['used']; + $result->destinationLimit = (int) $quota['total']; + $free = $result->destinationLimit - $result->destinationUsed; + $result->quotaFits = ($sourceBytes * self::PREFLIGHT_SAFETY_MARGIN) <= $free; + } + } catch (RoundcubeImapSyncException $quotaException) { + // Treat quota failures as unknown because runtime append still enforces quota. + } + + $this->source->close(); + + $result->readyToStart = $result->foldersOk + && $result->folderCount > 0 + && (!$result->quotaChecked || $result->quotaFits); + + return $result; + } + private function syncFolder( string $sourceFolder, string $destinationFolder, @@ -105,6 +171,8 @@ private function syncFolder( if ($dedupKey !== null) { $destinationKeys[$dedupKey] = true; } + } catch (RoundcubeImapSyncQuotaExceededException $quotaException) { + throw $quotaException; } catch (RoundcubeImapSyncException $messageException) { $result->errors[] = "Message {$uid} in {$sourceFolder}: {$messageException->getMessage()}"; } diff --git a/lib/RoundcubeImapSyncException.php b/lib/RoundcubeImapSyncException.php index 10749ae..1a2c2ca 100644 --- a/lib/RoundcubeImapSyncException.php +++ b/lib/RoundcubeImapSyncException.php @@ -3,3 +3,7 @@ class RoundcubeImapSyncException extends RuntimeException { } + +class RoundcubeImapSyncQuotaExceededException extends RoundcubeImapSyncException +{ +} diff --git a/lib/RoundcubeImapSyncPreflightResult.php b/lib/RoundcubeImapSyncPreflightResult.php new file mode 100644 index 0000000..eb16eae --- /dev/null +++ b/lib/RoundcubeImapSyncPreflightResult.php @@ -0,0 +1,33 @@ + $this->connectionOk, + 'connectionError' => $this->connectionError, + 'foldersOk' => $this->foldersOk, + 'folderError' => $this->folderError, + 'folderCount' => $this->folderCount, + 'sourceBytes' => $this->sourceBytes, + 'quotaChecked' => $this->quotaChecked, + 'destinationUsed' => $this->destinationUsed, + 'destinationLimit' => $this->destinationLimit, + 'quotaFits' => $this->quotaFits, + 'readyToStart' => $this->readyToStart, + ]; + } +} diff --git a/lib/RoundcubeImapSyncResult.php b/lib/RoundcubeImapSyncResult.php index ed2ce34..7a7c447 100644 --- a/lib/RoundcubeImapSyncResult.php +++ b/lib/RoundcubeImapSyncResult.php @@ -7,6 +7,7 @@ class RoundcubeImapSyncResult public int $messagesSkipped = 0; public array $errors = []; public ?string $fatalError = null; + public bool $quotaExceeded = false; public float $startedAt; public float $finishedAt = 0.0; @@ -23,6 +24,7 @@ public function toArray(): array 'messagesSkipped' => $this->messagesSkipped, 'errors' => $this->errors, 'fatalError' => $this->fatalError, + 'quotaExceeded' => $this->quotaExceeded, 'startedAt' => $this->startedAt, 'finishedAt' => $this->finishedAt, ]; diff --git a/localization/de_DE.inc b/localization/de_DE.inc index 069f12a..87bec04 100644 --- a/localization/de_DE.inc +++ b/localization/de_DE.inc @@ -12,6 +12,7 @@ $labels['noticetitle'] = 'Bitte beachten'; $labels['noticepreserves'] = 'Deine vorhandenen Mails bleiben unangetastet — der Umzug fügt nur die neuen Nachrichten aus dem Quellkonto zusätzlich hinzu. Es wird nichts überschrieben oder gelöscht.'; $labels['noticesynchronous'] = 'Lass diesen Tab geöffnet, bis der Vorgang abgeschlossen ist. Schließt du ihn vorher, läuft der Umzug möglicherweise nicht vollständig durch — und du siehst am Ende auch nicht, was tatsächlich kopiert wurde.'; $labels['noticeretry'] = 'Wenn etwas dazwischenkommt — Tab versehentlich geschlossen, kurz kein Internet, Timeout — starte nicht sofort neu. Der Vorgang läuft im Hintergrund oft noch ein paar Minuten weiter. Schau ins Postfach: sobald keine neuen Mails mehr eintreffen und die Postfachgröße nicht weiter wächst, ist er abgeschlossen und du kannst beruhigt erneut starten. Bereits kopierte Mails überspringt der nächste Lauf automatisch.'; +$labels['noticeretrylimit'] = 'Der Sync läuft bis zu %seconds% Sekunden. Wenn dein Browser vorher aufgibt (Tab geschlossen, kurz kein Internet, JavaScript-Timeout), läuft der Vorgang serverseitig bis zu dieser Grenze trotzdem weiter, auch wenn du nichts mehr siehst. Starte deshalb nicht sofort neu. Schau ins Postfach: sobald keine neuen Mails mehr eintreffen und die Postfachgröße nicht weiter wächst, ist er abgeschlossen und du kannst beruhigt erneut starten. Bereits kopierte Mails überspringt der nächste Lauf automatisch.'; $labels['noticeduration'] = 'Bei großen Postfächern kann das ein paar Minuten dauern. Während es läuft, siehst du keinen Fortschritt — die Übersicht erscheint erst am Ende.'; $labels['sourcehost'] = 'Quellserver'; $labels['sourceport'] = 'Quellport'; @@ -34,5 +35,20 @@ $labels['errorvalidation'] = 'Bitte prüfe die IMAP-Zugangsdaten der Quelle.'; $labels['errorconnect'] = 'Verbindung zum IMAP-Quellserver fehlgeschlagen.'; $labels['errorinsecure'] = 'Unverschlüsseltes IMAP wurde vom Administrator deaktiviert.'; $labels['errorhostblocked'] = 'Dieser Quellserver ist nicht erlaubt.'; +$labels['errorquota'] = 'Das Ziel-Postfach ist voll. Schaffe Platz (alte Nachrichten löschen oder den Administrator um eine Quota-Erhöhung bitten) und starte den Umzug erneut. Bereits kopierte Nachrichten werden beim nächsten Lauf übersprungen.'; +$labels['preflightbutton'] = 'Umzug prüfen'; +$labels['preflighttitle'] = 'Vorab-Prüfung'; +$labels['preflightcheckconnection'] = 'Verbindung zum Quellserver'; +$labels['preflightcheckfolders'] = 'Ordnerliste lesbar'; +$labels['preflightcheckquota'] = 'Ziel-Postfach hat voraussichtlich Platz'; +$labels['preflightfoldersdetail'] = '%count% Ordner gefunden'; +$labels['preflightquotaokdetail'] = 'Quelle: %source%, Ziel frei: %free%'; +$labels['preflightquotafaildetail'] = 'Quelle (%source%) passt nicht in %free% freien Speicher.'; +$labels['preflightquotaunknowndetail'] = 'Ziel-Server stellt keine Quota-Information bereit. Die Größe konnte nicht abgeschätzt werden.'; +$labels['preflighttimeoutwarn'] = 'Die Quelldaten (%source%) reichen voraussichtlich nicht in das Sync-Limit von %seconds% Sekunden. Falls der Sync ausläuft, starte ihn neu: bereits kopierte Mails werden übersprungen.'; +$labels['preflightreadyhint'] = 'Du kannst den Umzug jetzt starten.'; +$labels['preflightnotreadyhint'] = 'Behebe die Punkte oben und prüfe erneut.'; $labels['donesuccess'] = 'Umzug erfolgreich abgeschlossen.'; $labels['donewitherrors'] = 'Umzug mit Fehlern abgeschlossen.'; +$labels['supportnotice'] = 'Fragen oder Probleme? %link%'; +$labels['supportnoticelink'] = 'Ein Issue auf GitHub erstellen'; diff --git a/localization/en_US.inc b/localization/en_US.inc index 045c10a..06f96fc 100644 --- a/localization/en_US.inc +++ b/localization/en_US.inc @@ -12,6 +12,7 @@ $labels['noticetitle'] = 'Before you start'; $labels['noticepreserves'] = 'Your existing messages stay untouched — the migration only adds new ones from the source account on top of what is already here. Nothing is overwritten or deleted.'; $labels['noticesynchronous'] = 'Keep this tab open until the migration finishes. If you close it earlier, the migration may not run to completion — and you won\'t see what was actually copied either.'; $labels['noticeretry'] = 'If something cuts the migration short — you closed the tab, your internet dropped for a moment, a timeout hit — don\'t restart right away. The previous run often keeps going for a few more minutes in the background. Check your mailbox: once new messages stop arriving and the mailbox size stops growing, it\'s done and you can safely start over. The next run skips messages that are already there.'; +$labels['noticeretrylimit'] = 'The sync runs for up to %seconds% seconds. If your browser gives up earlier (tab closed, brief network blip, JavaScript timeout), the migration keeps going on the server up to that limit even though you cannot see it. So do not restart right away. Check your mailbox: once new messages stop arriving and the mailbox size stops growing, it is done and you can safely start over. Already-copied messages are skipped on the next run.'; $labels['noticeduration'] = 'Large mailboxes can take a few minutes. You won\'t see progress while it runs — the summary shows up at the end.'; $labels['sourcehost'] = 'Source host'; $labels['sourceport'] = 'Source port'; @@ -34,5 +35,20 @@ $labels['errorvalidation'] = 'Please check the source IMAP settings.'; $labels['errorconnect'] = 'Could not connect to the source IMAP server.'; $labels['errorinsecure'] = 'Plaintext IMAP is disabled by the administrator.'; $labels['errorhostblocked'] = 'This source host is not allowed.'; +$labels['errorquota'] = 'The target mailbox is full. Free up space (delete old messages, or ask your administrator to increase the quota) and start the migration again. Already-copied messages will be skipped on the next run.'; +$labels['preflightbutton'] = 'Verify migration'; +$labels['preflighttitle'] = 'Pre-flight check'; +$labels['preflightcheckconnection'] = 'Connection to source server'; +$labels['preflightcheckfolders'] = 'Folder list readable'; +$labels['preflightcheckquota'] = 'Target mailbox has enough space'; +$labels['preflightfoldersdetail'] = '%count% folders found'; +$labels['preflightquotaokdetail'] = 'Source: %source%, destination free: %free%'; +$labels['preflightquotafaildetail'] = 'Source (%source%) does not fit into %free% of free space.'; +$labels['preflightquotaunknowndetail'] = 'Target server does not provide quota information. Size could not be estimated.'; +$labels['preflighttimeoutwarn'] = 'Source data (%source%) may not complete within the %seconds% second sync limit. If the sync times out, restart it: already-copied messages are skipped.'; +$labels['preflightreadyhint'] = 'You can start the migration now.'; +$labels['preflightnotreadyhint'] = 'Fix the issues above and verify again.'; $labels['donesuccess'] = 'Migration completed successfully.'; $labels['donewitherrors'] = 'Migration completed with errors.'; +$labels['supportnotice'] = 'Questions or running into trouble? %link%'; +$labels['supportnoticelink'] = 'Open an issue on GitHub'; diff --git a/skins/elastic/imapsync.css b/skins/elastic/imapsync.css index 7e28272..897badc 100644 --- a/skins/elastic/imapsync.css +++ b/skins/elastic/imapsync.css @@ -24,6 +24,12 @@ margin: 0.2rem 0; } +.imapsync-support { + margin: 1.25rem 0 0; + color: var(--color-font-secondary, #6c757d); + font-size: 0.9em; +} + .imapsync-form-table td.title { width: 14rem; } @@ -93,6 +99,44 @@ margin: 0.25rem 0; } +.imapsync-checklist { + margin: 0; + padding: 0; + list-style: none; +} + +.imapsync-check-row { + display: grid; + grid-template-columns: 1.5rem 1fr; + column-gap: 0.6rem; + align-items: baseline; + padding: 0.45rem 0; + border-bottom: 1px solid var(--border-color, #ddd); +} + +.imapsync-check-row:last-child { + border-bottom: 0; +} + +.imapsync-check-icon { + font-weight: bold; + text-align: center; +} + +.imapsync-check-ok { color: var(--color-success, #2ecc71); } +.imapsync-check-fail { color: var(--color-error, #e74c3c); } +.imapsync-check-warn { color: var(--color-warning, #f39c12); } + +.imapsync-check-detail { + display: block; + color: var(--color-font-secondary, #6c757d); + font-size: 0.9em; +} + +.imapsync-preflight-hint { + margin: 1rem 0 0; +} + @media screen and (max-width: 600px) { .imapsync-form-table td.title, .imapsync-form-table td { diff --git a/skins/elastic/templates/imapsync.html b/skins/elastic/templates/imapsync.html index d6a4f6d..e105780 100644 --- a/skins/elastic/templates/imapsync.html +++ b/skins/elastic/templates/imapsync.html @@ -12,6 +12,10 @@

:
+