From db0d230d3b5cdd8cb0e1925453a91c91e8f355e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 2 Nov 2026 09:37:35 +0100 Subject: [PATCH] v0.2.10 --- RELEASE-MANIFEST.txt | 1 + docs/VALIDATION.md | 48 ++ scripts/migrate-roundcube.sh | 4 + scripts/migrate_roundcube.py | 942 +++++++++++++++++++++ scripts/requirements-roundcube-migrate.txt | 3 + src/web/css/10-base.css | 7 +- src/web/css/50-settings.css | 12 + src/web/i18n/en.json | 2 +- src/web/i18n/pl.json | 6 +- src/web/index.html | 12 +- src/web/js/20-preferences-auth.js | 112 ++- src/web/js/40-messages-reader.js | 14 + 12 files changed, 1146 insertions(+), 17 deletions(-) create mode 100755 scripts/migrate-roundcube.sh create mode 100755 scripts/migrate_roundcube.py create mode 100644 scripts/requirements-roundcube-migrate.txt diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 1207906..308d9ce 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -43,6 +43,7 @@ Docker examples: - docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch) Operations/docs: +- standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets - README.md documents 0.2.10 configuration - docker/README.md documents Compose variants - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index e14b258..1be06e2 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -25,3 +25,51 @@ Full release gate on a host with Rust/Cargo and dependency access: ``` The release script runs formatting validation, locked tests, Clippy with warnings denied, and a locked release build before accepting the binary. + +## Roundcube contacts and identities migration + +The migration is a standalone Python administration tool: `scripts/migrate_roundcube.py`. It does not start RustBox and is not exposed by the application UI/API. + +By default it reads RustBox database settings from `RUSTBOX_CONFIG` or `/etc/rustbox/rustbox.toml`, recognizes `database_backend = "sqlite" | "mysql" | "pgsql"`, and uses `database_url`. For SQLite without `database_url`, it follows RustBox behavior and uses `data_dir/rustbox.sqlite`. + +CLI values can override the target independently with `--config`, `--target-backend` and `--target-url`. The Roundcube source is MySQL and can be supplied either as `--roundcube-url` or as `--roundcube-host`, `--roundcube-port`, `--roundcube-database`, `--roundcube-db-user` and `--roundcube-db-password`. + +The default mode migrates every matching Roundcube user. RustBox accounts must already be provisioned so their isolated table prefixes exist in `rustbox_accounts`. Unmatched users are skipped and reported. A single account can be selected with `--roundcube-account`. + +Dependencies: + +```bash +python3 -m pip install pymysql +# additionally only for a PostgreSQL RustBox target: +python3 -m pip install 'psycopg[binary]' +``` + +Recommended dry run using the active RustBox config: + +```bash +./scripts/migrate_roundcube.py \ + --roundcube-url 'mysql://roundcube:secret@127.0.0.1/roundcube' \ + --dry-run +``` + +Run the migration: + +```bash +./scripts/migrate_roundcube.py \ + --roundcube-url 'mysql://roundcube:secret@127.0.0.1/roundcube' +``` + +Example with explicit Roundcube connection fields and an overridden MySQL RustBox target: + +```bash +./scripts/migrate_roundcube.py \ + --config /etc/rustbox/rustbox.toml \ + --target-backend mysql \ + --target-url 'mysql://rustbox:secret@db/rustbox' \ + --roundcube-host rc-db \ + --roundcube-database roundcube \ + --roundcube-db-user roundcube \ + --roundcube-db-password secret +``` + +Use `--roundcube-prefix rc_` when Roundcube uses prefixed table names. The migration is idempotent for practical reruns: contacts are matched by email and identities by their stable identity fields before insert/update. diff --git a/scripts/migrate-roundcube.sh b/scripts/migrate-roundcube.sh new file mode 100755 index 0000000..3ba1a80 --- /dev/null +++ b/scripts/migrate-roundcube.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +exec cargo run --bin roundcube-migrate -- "$@" diff --git a/scripts/migrate_roundcube.py b/scripts/migrate_roundcube.py new file mode 100755 index 0000000..684d7f3 --- /dev/null +++ b/scripts/migrate_roundcube.py @@ -0,0 +1,942 @@ +#!/usr/bin/env python3 +"""Standalone Roundcube -> RustBox contacts/identities migrator. + +The tool does not import or start RustBox. By default it reads RustBox's database +configuration from the same TOML file as the application, detects SQLite/MySQL/ +PostgreSQL, and writes directly to the already provisioned per-account tables. +""" + +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import os +import re +import sqlite3 +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence +from urllib.parse import parse_qs, quote, unquote, urlparse + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 + try: + import tomli as tomllib # type: ignore[no-redef] + except ModuleNotFoundError as exc: # pragma: no cover + raise SystemExit( + "Python 3.11+ is recommended. On older Python install 'tomli'." + ) from exc + + +EMAIL_RE = re.compile( + r"[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@" + r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" + r"(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+", + re.IGNORECASE, +) +IDENT_RE = re.compile(r"^[A-Za-z0-9_]+$") +BREAK_RE = re.compile(r"|
Markdown is stored as source text and sent as a safe rendered HTML alternative with a diff --git a/src/web/js/20-preferences-auth.js b/src/web/js/20-preferences-auth.js index 5da9d20..26588c3 100644 --- a/src/web/js/20-preferences-auth.js +++ b/src/web/js/20-preferences-auth.js @@ -16,6 +16,10 @@ const preferenceMap = { compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background", }; const preferenceTimers = new Map(); +let presetPromotionTimer = null; +let presetPromotionInFlight = false; +let presetPromotionNeedsAnotherSave = false; +let presetPromotionActive = false; const interfaceStyleProfiles = { "roundcube-classic": { @@ -188,7 +192,7 @@ function applyPreferences() { const p = state.prefs; const fixedStyle = activeInterfaceStyle(p); const visual = fixedStyle - ? { ...p, ...fixedStyle.preferences, scale: p.presetScaleUnlocked ? p.scale : fixedStyle.preferences.scale } + ? { ...p, ...fixedStyle.preferences } : p; root.dataset.theme = resolveTheme(p.theme); root.dataset.interfaceSkin = resolvedInterfaceSkin(p); @@ -247,10 +251,19 @@ function applyPreferences() { root.style.setProperty("--radius-control", `${visual.borderRadius}px`); root.style.setProperty("--radius-panel", `${Math.min(visual.borderRadius + 1, 13)}px`); + const presetCss = fixedStyle ? getComputedStyle(root) : null; + const presetColor = (name, fallback) => presetCss ? presetCssHex(presetCss, name, fallback) : fallback; + const displayedAccent = presetColor("--accent", p.accentColor); + const displayedBackground = presetColor("--bg", p.backgroundColor); + const displayedPanel = presetColor("--panel", p.panelColor); + const displayedText = presetColor("--text", p.textColor); + const displayedFolderBackground = presetColor("--folder-bg", p.folderBackground || "#101310"); + const displayedMessageBackground = presetColor("--message-bg", p.messageBackground || "#181c19"); + const displayedReaderBackground = presetColor("--reader-bg", p.readerBackground || "#181c19"); const values = { "#pref-theme": p.theme, "#pref-interface-skin": p.interfaceSkin, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale, - "#pref-language": p.language, "#pref-color-preset": p.colorPreset, "#pref-accent": p.accentColor, - "#pref-background": p.backgroundColor, "#pref-panel": p.panelColor, "#pref-text-color": p.textColor, + "#pref-language": p.language, "#pref-color-preset": fixedStyle ? "custom" : p.colorPreset, "#pref-accent": displayedAccent, + "#pref-background": displayedBackground, "#pref-panel": displayedPanel, "#pref-text-color": displayedText, "#pref-font-family": visual.fontFamily, "#pref-font-size": visual.fontSize, "#pref-row-height": visual.rowHeight, "#pref-folder-width": visual.folderPaneWidth, "#pref-message-width": visual.messagePaneWidth, "#pref-radius": visual.borderRadius, "#pref-contrast": visual.contrast, "#pref-date-format": p.dateFormat, "#pref-time-format": p.timeFormat, @@ -261,9 +274,9 @@ function applyPreferences() { Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); const checks = { "#pref-preset-scale-unlocked": p.presetScaleUnlocked, "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) }; Object.entries(checks).forEach(([selector, value]) => { const node = $(selector); if (node) node.checked = Boolean(value); }); - const outputs = { "#pref-accent-value": p.accentColor, "#pref-background-value": p.backgroundColor, "#pref-panel-value": p.panelColor, "#pref-text-color-value": p.textColor, "#pref-font-size-value": `${visual.fontSize} px`, "#pref-row-height-value": `${visual.rowHeight} px`, "#pref-folder-width-value": `${visual.folderPaneWidth} px`, "#pref-message-width-value": `${visual.messagePaneWidth} px`, "#pref-radius-value": `${visual.borderRadius} px`, "#pref-icon-size-value": `${visual.iconSize} px`, "#pref-sidebar-height-value": `${visual.sidebarItemHeight} px`, "#pref-composer-width-value": `${visual.composerWidth} px`, "#pref-selection-color-value": visual.selectionColor, "#pref-unread-weight-value": String(visual.unreadFontWeight) }; + const outputs = { "#pref-accent-value": displayedAccent, "#pref-background-value": displayedBackground, "#pref-panel-value": displayedPanel, "#pref-text-color-value": displayedText, "#pref-font-size-value": `${visual.fontSize} px`, "#pref-row-height-value": `${visual.rowHeight} px`, "#pref-folder-width-value": `${visual.folderPaneWidth} px`, "#pref-message-width-value": `${visual.messagePaneWidth} px`, "#pref-radius-value": `${visual.borderRadius} px`, "#pref-icon-size-value": `${visual.iconSize} px`, "#pref-sidebar-height-value": `${visual.sidebarItemHeight} px`, "#pref-composer-width-value": `${visual.composerWidth} px`, "#pref-selection-color-value": visual.selectionColor, "#pref-unread-weight-value": String(visual.unreadFontWeight) }; Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; }); - [["#pref-folder-bg", p.folderBackground || "#101310"], ["#pref-message-bg", p.messageBackground || "#181c19"], ["#pref-reader-bg", p.readerBackground || "#181c19"]].forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); + [["#pref-folder-bg", displayedFolderBackground], ["#pref-message-bg", displayedMessageBackground], ["#pref-reader-bg", displayedReaderBackground]].forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); const customizationLocked = Boolean(fixedStyle); [ "#pref-color-preset", "#pref-accent", "#pref-background", "#pref-panel", "#pref-text-color", @@ -273,7 +286,7 @@ function applyPreferences() { "#pref-selection-color", "#pref-unread-weight", "#pref-unread-badge", "#pref-scrollbar-density", "#pref-compact-headers", "#pref-show-borders", "#pref-monochrome", "#pref-folder-bg-enabled", "#pref-folder-bg", "#pref-message-bg-enabled", "#pref-message-bg", "#pref-reader-bg-enabled", "#pref-reader-bg", - ].forEach((selector) => { const node = $(selector); if (node) node.disabled = customizationLocked; }); + ].forEach((selector) => { const node = $(selector); if (node) node.disabled = customizationLocked && !p.presetScaleUnlocked; }); const scaleControl = $("#pref-scale"); if (scaleControl) scaleControl.disabled = customizationLocked && !p.presetScaleUnlocked; const scaleUnlock = $("#pref-preset-scale-unlocked"); @@ -284,7 +297,7 @@ function applyPreferences() { if (skinNote) { const styleLabel = state.prefs.language === "pl" ? (state.translations._text?.[fixedStyle?.label] || fixedStyle?.label) : fixedStyle?.label; skinNote.textContent = fixedStyle - ? `${styleLabel} — ${tr("fixed_interface_style_note", "fixed interface style. Theme still switches light/dark; choose RustBox (custom) to edit colors, spacing and geometry.")}` + ? `${styleLabel} — ${tr("fixed_interface_style_note", "predefined interface style. Enable preset editing below to create a customizable copy on the first change.")}` : tr("custom_interface_style_note", "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below."); } } @@ -387,8 +400,93 @@ async function reconcileImapLabels() { } } +const presetEditablePreferenceKeys = new Set([ + "colorPreset", "accentColor", "backgroundColor", "panelColor", "textColor", + "density", "preview", "scale", "fontFamily", "fontSize", "rowHeight", + "folderPaneWidth", "messagePaneWidth", "borderRadius", "contrast", + "iconSize", "toolbarDensity", "sidebarItemHeight", "readerMaxWidth", "composerWidth", + "selectionColor", "unreadFontWeight", "unreadBadgeStyle", "scrollbarDensity", + "compactHeaders", "showBorders", "monochrome", + "folderBackground", "messageBackground", "readerBackground", +]); + +function presetCssHex(styles, name, fallback) { + const value = styles.getPropertyValue(name).trim(); + if (/^#[0-9a-f]{6}$/i.test(value)) return value; + const shortHex = value.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); + if (shortHex) return `#${shortHex.slice(1).map((part) => part + part).join("")}`; + return fallback; +} + +function customAppearancePayload(prefs) { + const keys = [ + "interfaceSkin", "presetScaleUnlocked", "colorPreset", "accentColor", "backgroundColor", "panelColor", "textColor", + "density", "preview", "scale", "fontFamily", "fontSize", "rowHeight", "folderPaneWidth", "messagePaneWidth", + "borderRadius", "contrast", "iconSize", "toolbarDensity", "sidebarItemHeight", "readerMaxWidth", "composerWidth", + "selectionColor", "unreadFontWeight", "unreadBadgeStyle", "scrollbarDensity", "compactHeaders", "showBorders", "monochrome", + "folderBackground", "messageBackground", "readerBackground", + ]; + return Object.fromEntries(keys.map((key) => [preferenceMap[key], prefs[key]]).filter(([field]) => field)); +} + +function schedulePromotedAppearanceSave() { + if (presetPromotionTimer) clearTimeout(presetPromotionTimer); + if (presetPromotionInFlight) { + presetPromotionNeedsAnotherSave = true; + return; + } + presetPromotionTimer = setTimeout(async () => { + presetPromotionTimer = null; + presetPromotionInFlight = true; + presetPromotionNeedsAnotherSave = false; + try { + await request("/settings/display", { method: "PUT", body: JSON.stringify(customAppearancePayload(state.prefs)) }); + } catch (error) { + toast(error.message, "error"); + } finally { + presetPromotionInFlight = false; + if (presetPromotionNeedsAnotherSave) schedulePromotedAppearanceSave(); + else presetPromotionActive = false; + } + }, 180); +} + +function promotePresetToCustom(key, value) { + const fixedStyle = activeInterfaceStyle(state.prefs); + if (!fixedStyle || !state.prefs.presetScaleUnlocked || !presetEditablePreferenceKeys.has(key)) return false; + + const styles = getComputedStyle(root); + state.prefs = { + ...state.prefs, + ...fixedStyle.preferences, + interfaceSkin: "rustbox", + presetScaleUnlocked: false, + colorPreset: "custom", + accentColor: presetCssHex(styles, "--accent", state.prefs.accentColor), + backgroundColor: presetCssHex(styles, "--bg", state.prefs.backgroundColor), + panelColor: presetCssHex(styles, "--panel", state.prefs.panelColor), + textColor: presetCssHex(styles, "--text", state.prefs.textColor), + selectionColor: presetCssHex(styles, "--selected", fixedStyle.preferences.selectionColor || state.prefs.selectionColor), + folderBackground: presetCssHex(styles, "--folder-bg", state.prefs.folderBackground || "#101310"), + messageBackground: presetCssHex(styles, "--message-bg", state.prefs.messageBackground || "#181c19"), + readerBackground: presetCssHex(styles, "--reader-bg", state.prefs.readerBackground || "#181c19"), + [key]: value, + }; + presetPromotionActive = true; + applyPreferences(); + schedulePromotedAppearanceSave(); + return true; +} + function savePreference(key, value) { + if (promotePresetToCustom(key, value)) return; state.prefs[key] = value; + if (presetPromotionActive && presetEditablePreferenceKeys.has(key)) { + if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom"; + applyPreferences(); + schedulePromotedAppearanceSave(); + return; + } if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom"; applyPreferences(); if (key === "language") loadTranslations(value); diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index b1e9d4e..172ad19 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -728,6 +728,13 @@ function attachmentContentUrl(folder, uid, attachment) { return `${path}${path.includes("?") ? "&" : "?"}account_id=${encodeURIComponent(state.accountId)}`; } +function needsStandaloneIosPdfPreview() { + const ua = navigator.userAgent || ""; + const iosDevice = /iPad|iPhone|iPod/i.test(ua); + const ipadDesktopMode = navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1; + return iosDevice || ipadDesktopMode; +} + function attachmentPreviewLimitBytes(attachment) { const mime = attachmentMime(attachment); const filename = String(attachment?.filename || "").toLowerCase(); @@ -768,6 +775,13 @@ async function openAttachment(folder, uid, attachment) { const declaredMime = attachmentMime(attachment); const filename = String(attachment?.filename || "").toLowerCase(); const isPdf = declaredMime === "application/pdf" || filename.endsWith(".pdf"); + if (isPdf && needsStandaloneIosPdfPreview()) { + const previewUrl = attachmentContentUrl(folder, uid, attachment); + const previewWindow = window.open(previewUrl, "_blank"); + if (previewWindow) previewWindow.opener = null; + else window.location.assign(previewUrl); + return; + } const blob = isPdf ? null : await fetchAttachmentBlob(folder, uid, attachment); const mime = isPdf ? "application/pdf" : attachmentMime(attachment, blob); const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);