Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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`.
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

---
Expand Down
211 changes: 205 additions & 6 deletions imapsync.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,49 @@ 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');
}

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) {
cancelButton.hidden = !disabled;
}
}

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');
Expand Down Expand Up @@ -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'),
Expand All @@ -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));
}

Expand Down Expand Up @@ -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');
});
Expand All @@ -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;
}
Expand All @@ -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);
}
});
});
}
Loading
Loading