1 parent
02d0b13ec1
commit
db0d230d3b
12 files changed
+1146
-17
No files matched your search
@@ -43,6 +43,7 @@ Docker examples:
|
|||||||
- docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch)
|
- docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch)
|
||||||
|
|
||||||
Operations/docs:
|
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
|
- README.md documents 0.2.10 configuration
|
||||||
- docker/README.md documents Compose variants
|
- docker/README.md documents Compose variants
|
||||||
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
exec cargo run --bin roundcube-migrate -- "$@"
|
||||||
Executable
+942
@@ -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"<br\s*/?>|</p\s*>|</div\s*>", re.IGNORECASE)
|
||||||
|
TAG_RE = re.compile(r"<[^>]*>", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Contact:
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
company: str
|
||||||
|
notes: str
|
||||||
|
phone: str
|
||||||
|
job_title: str
|
||||||
|
address: str
|
||||||
|
website: str
|
||||||
|
birthday: str
|
||||||
|
emails_json: str
|
||||||
|
phones_json: str
|
||||||
|
addresses_json: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Identity:
|
||||||
|
display_name: str
|
||||||
|
email: str
|
||||||
|
signature_html: str
|
||||||
|
signature_text: str
|
||||||
|
is_default: bool
|
||||||
|
reply_to: str
|
||||||
|
bcc: str
|
||||||
|
organization: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VCard:
|
||||||
|
name: str = ""
|
||||||
|
emails: list[str] | None = None
|
||||||
|
phones: list[str] | None = None
|
||||||
|
addresses: list[str] | None = None
|
||||||
|
company: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
job_title: str = ""
|
||||||
|
website: str = ""
|
||||||
|
birthday: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.emails = self.emails or []
|
||||||
|
self.phones = self.phones or []
|
||||||
|
self.addresses = self.addresses or []
|
||||||
|
|
||||||
|
|
||||||
|
class Db:
|
||||||
|
"""Small DB-API adapter for SQLite, PyMySQL and psycopg/psycopg2."""
|
||||||
|
|
||||||
|
def __init__(self, backend: str, conn: Any):
|
||||||
|
self.backend = backend
|
||||||
|
self.conn = conn
|
||||||
|
|
||||||
|
@property
|
||||||
|
def placeholder(self) -> str:
|
||||||
|
return "?" if self.backend == "sqlite" else "%s"
|
||||||
|
|
||||||
|
def ident(self, value: str) -> str:
|
||||||
|
validate_identifier(value, "SQL identifier")
|
||||||
|
if self.backend == "mysql":
|
||||||
|
return f"`{value}`"
|
||||||
|
return f'"{value}"'
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: Sequence[Any] = ()) -> Any:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute(sql, tuple(params))
|
||||||
|
return cur
|
||||||
|
|
||||||
|
def fetchone(self, sql: str, params: Sequence[Any] = ()) -> Any | None:
|
||||||
|
cur = self.execute(sql, params)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row
|
||||||
|
|
||||||
|
def fetchall(self, sql: str, params: Sequence[Any] = ()) -> list[Any]:
|
||||||
|
cur = self.execute(sql, params)
|
||||||
|
rows = list(cur.fetchall())
|
||||||
|
cur.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
self.conn.rollback()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Migrate Roundcube MySQL contacts and identities directly into RustBox.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Default RustBox config precedence:
|
||||||
|
--config > RUSTBOX_CONFIG > /etc/rustbox/rustbox.toml
|
||||||
|
|
||||||
|
Target DB precedence:
|
||||||
|
--target-backend/--target-url > DATABASE_BACKEND/DATABASE_URL > config.toml
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# all users, RustBox target read from config.toml
|
||||||
|
scripts/migrate_roundcube.py --roundcube-url mysql://rc:secret@db/roundcube --dry-run
|
||||||
|
scripts/migrate_roundcube.py --roundcube-url mysql://rc:secret@db/roundcube
|
||||||
|
|
||||||
|
# override both sides
|
||||||
|
scripts/migrate_roundcube.py \\
|
||||||
|
--config ./config.toml \\
|
||||||
|
--target-backend mysql \\
|
||||||
|
--target-url mysql://rustbox:secret@db/rustbox \\
|
||||||
|
--roundcube-host rc-db --roundcube-database roundcube \\
|
||||||
|
--roundcube-db-user rc --roundcube-db-password secret
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
MySQL source/target: PyMySQL (pip install pymysql)
|
||||||
|
PostgreSQL target: psycopg (pip install 'psycopg[binary]')
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
p.add_argument("--config", help="RustBox TOML config path")
|
||||||
|
p.add_argument(
|
||||||
|
"--target-backend",
|
||||||
|
choices=("sqlite", "mysql", "pgsql", "postgres", "postgresql"),
|
||||||
|
help="override RustBox DB backend",
|
||||||
|
)
|
||||||
|
p.add_argument("--target-url", help="override RustBox DB URL/path")
|
||||||
|
|
||||||
|
rc = p.add_argument_group("Roundcube MySQL source")
|
||||||
|
rc.add_argument("--roundcube-url", help="mysql://user:password@host:3306/database")
|
||||||
|
rc.add_argument("--roundcube-host", help="Roundcube MySQL host")
|
||||||
|
rc.add_argument("--roundcube-port", type=int, default=None, help="Roundcube MySQL port (default 3306)")
|
||||||
|
rc.add_argument("--roundcube-database", help="Roundcube database name")
|
||||||
|
rc.add_argument("--roundcube-db-user", help="Roundcube database user")
|
||||||
|
rc.add_argument("--roundcube-db-password", help="Roundcube database password")
|
||||||
|
rc.add_argument("--roundcube-prefix", default=os.getenv("ROUNDCUBE_TABLE_PREFIX", ""), help="table prefix, e.g. rc_")
|
||||||
|
|
||||||
|
mode = p.add_mutually_exclusive_group()
|
||||||
|
mode.add_argument("--all-users", action="store_true", help="migrate every matching Roundcube user (default)")
|
||||||
|
mode.add_argument("--roundcube-account", help="migrate only this Roundcube users.username")
|
||||||
|
|
||||||
|
p.add_argument("--rustbox-user", help="single-user target login if different from Roundcube username")
|
||||||
|
p.add_argument("--user-hash", help="single-user RustBox user hash override")
|
||||||
|
p.add_argument("--target-prefix", help="single-user RustBox tenant prefix override")
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="read/map only; do not write")
|
||||||
|
p.add_argument("--fail-fast", action="store_true", help="stop all-users migration after first user failure")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def validate_identifier(value: str, label: str, allow_empty: bool = False) -> None:
|
||||||
|
if value == "" and allow_empty:
|
||||||
|
return
|
||||||
|
if not value or not IDENT_RE.fullmatch(value):
|
||||||
|
raise ValueError(f"{label} may contain only ASCII letters, digits and underscore")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_backend(raw: str | None) -> str | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
value = raw.strip().lower()
|
||||||
|
aliases = {"postgres": "pgsql", "postgresql": "pgsql"}
|
||||||
|
value = aliases.get(value, value)
|
||||||
|
if value not in {"sqlite", "mysql", "pgsql"}:
|
||||||
|
raise ValueError(f"unsupported database backend: {raw}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def infer_backend_from_url(raw: str | None) -> str | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
value = raw.strip().lower()
|
||||||
|
if value.startswith("mysql://"):
|
||||||
|
return "mysql"
|
||||||
|
if value.startswith("postgres://") or value.startswith("postgresql://"):
|
||||||
|
return "pgsql"
|
||||||
|
if value.startswith("sqlite:"):
|
||||||
|
return "sqlite"
|
||||||
|
if "://" not in value:
|
||||||
|
return "sqlite"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
with path.open("rb") as fh:
|
||||||
|
data = tomllib.load(fh)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"invalid TOML root in {path}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_path(raw: str) -> Path:
|
||||||
|
value = raw.strip()
|
||||||
|
if value.startswith("sqlite://"):
|
||||||
|
value = value[len("sqlite://") :]
|
||||||
|
elif value.startswith("sqlite:"):
|
||||||
|
value = value[len("sqlite:") :]
|
||||||
|
value = value.split("?", 1)[0].strip()
|
||||||
|
if not value or value == ":memory:":
|
||||||
|
raise ValueError("SQLite target must point to a persistent database file")
|
||||||
|
return Path(value)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target(args: argparse.Namespace) -> tuple[Path, dict[str, Any], str, str]:
|
||||||
|
config_path = Path(
|
||||||
|
args.config
|
||||||
|
or os.getenv("RUSTBOX_CONFIG")
|
||||||
|
or "/etc/rustbox/rustbox.toml"
|
||||||
|
)
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
env_backend = os.getenv("DATABASE_BACKEND") or os.getenv("RUSTBOX_DATABASE_BACKEND")
|
||||||
|
env_url = os.getenv("DATABASE_URL") or os.getenv("RUSTBOX_DATABASE_URL")
|
||||||
|
env_data_dir = os.getenv("DATA_DIR")
|
||||||
|
|
||||||
|
explicit_url_backend = infer_backend_from_url(args.target_url) if args.target_url else None
|
||||||
|
backend = normalize_backend(
|
||||||
|
args.target_backend
|
||||||
|
or explicit_url_backend
|
||||||
|
or env_backend
|
||||||
|
or config.get("database_backend")
|
||||||
|
or "sqlite"
|
||||||
|
)
|
||||||
|
assert backend is not None
|
||||||
|
|
||||||
|
target_url = args.target_url or env_url or config.get("database_url")
|
||||||
|
if backend == "sqlite":
|
||||||
|
if target_url:
|
||||||
|
target_url = str(sqlite_path(str(target_url)))
|
||||||
|
else:
|
||||||
|
data_dir = str(env_data_dir or config.get("data_dir") or "/data")
|
||||||
|
target_url = str(Path(data_dir) / "rustbox.sqlite")
|
||||||
|
elif not target_url:
|
||||||
|
raise ValueError(
|
||||||
|
f"database_url is required for {backend}; set it in config.toml or pass --target-url"
|
||||||
|
)
|
||||||
|
|
||||||
|
inferred = infer_backend_from_url(str(target_url))
|
||||||
|
if inferred is not None and inferred != backend:
|
||||||
|
raise ValueError(
|
||||||
|
f"target backend is {backend}, but target URL looks like {inferred}; "
|
||||||
|
"override both --target-backend and --target-url together"
|
||||||
|
)
|
||||||
|
if not config and not (args.target_url or env_url):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"RustBox config not found: {config_path}; pass --config or --target-url"
|
||||||
|
)
|
||||||
|
return config_path, config, backend, str(target_url)
|
||||||
|
|
||||||
|
|
||||||
|
def build_roundcube_url(args: argparse.Namespace) -> str:
|
||||||
|
env_url = os.getenv("ROUNDCUBE_DATABASE_URL")
|
||||||
|
if args.roundcube_url:
|
||||||
|
return args.roundcube_url
|
||||||
|
if env_url and not any(
|
||||||
|
[
|
||||||
|
args.roundcube_host,
|
||||||
|
args.roundcube_database,
|
||||||
|
args.roundcube_db_user,
|
||||||
|
args.roundcube_db_password,
|
||||||
|
args.roundcube_port,
|
||||||
|
]
|
||||||
|
):
|
||||||
|
return env_url
|
||||||
|
|
||||||
|
host = args.roundcube_host or os.getenv("ROUNDCUBE_HOST")
|
||||||
|
port = args.roundcube_port or int(os.getenv("ROUNDCUBE_PORT", "3306"))
|
||||||
|
database = args.roundcube_database or os.getenv("ROUNDCUBE_DATABASE")
|
||||||
|
user = args.roundcube_db_user or os.getenv("ROUNDCUBE_DB_USER")
|
||||||
|
password = (
|
||||||
|
args.roundcube_db_password
|
||||||
|
if args.roundcube_db_password is not None
|
||||||
|
else os.getenv("ROUNDCUBE_DB_PASSWORD", "")
|
||||||
|
)
|
||||||
|
if not all([host, database, user]):
|
||||||
|
raise ValueError(
|
||||||
|
"Roundcube source is required: pass --roundcube-url or "
|
||||||
|
"--roundcube-host/--roundcube-database/--roundcube-db-user"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"mysql://{quote(str(user), safe='')}:{quote(str(password), safe='')}@"
|
||||||
|
f"{host}:{port}/{quote(str(database), safe='')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mysql_url(raw: str) -> dict[str, Any]:
|
||||||
|
parsed = urlparse(raw)
|
||||||
|
if parsed.scheme.lower() not in {"mysql", "mariadb"}:
|
||||||
|
raise ValueError("MySQL URL must start with mysql://")
|
||||||
|
database = unquote(parsed.path.lstrip("/"))
|
||||||
|
if not parsed.hostname or not parsed.username or not database:
|
||||||
|
raise ValueError("MySQL URL must contain user, host and database")
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"host": parsed.hostname,
|
||||||
|
"port": parsed.port or 3306,
|
||||||
|
"user": unquote(parsed.username),
|
||||||
|
"password": unquote(parsed.password or ""),
|
||||||
|
"database": database,
|
||||||
|
"charset": query.get("charset", ["utf8mb4"])[0],
|
||||||
|
"autocommit": False,
|
||||||
|
}
|
||||||
|
if "unix_socket" in query:
|
||||||
|
out["unix_socket"] = query["unix_socket"][0]
|
||||||
|
if "connect_timeout" in query:
|
||||||
|
out["connect_timeout"] = int(query["connect_timeout"][0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def connect_mysql(raw: str, *, dict_rows: bool) -> Db:
|
||||||
|
try:
|
||||||
|
import pymysql # type: ignore
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"PyMySQL is required for Roundcube/MySQL. Install: python3 -m pip install pymysql"
|
||||||
|
) from exc
|
||||||
|
kwargs = parse_mysql_url(raw)
|
||||||
|
if dict_rows:
|
||||||
|
kwargs["cursorclass"] = pymysql.cursors.DictCursor
|
||||||
|
conn = pymysql.connect(**kwargs)
|
||||||
|
return Db("mysql", conn)
|
||||||
|
|
||||||
|
|
||||||
|
def connect_pgsql(raw: str) -> Db:
|
||||||
|
try:
|
||||||
|
import psycopg # type: ignore
|
||||||
|
|
||||||
|
return Db("pgsql", psycopg.connect(raw, autocommit=False))
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
try:
|
||||||
|
import psycopg2 # type: ignore
|
||||||
|
|
||||||
|
return Db("pgsql", psycopg2.connect(raw))
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"PostgreSQL target requires psycopg. Install: python3 -m pip install 'psycopg[binary]'"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def connect_target(backend: str, raw: str) -> Db:
|
||||||
|
if backend == "sqlite":
|
||||||
|
path = sqlite_path(raw)
|
||||||
|
conn = sqlite3.connect(path, timeout=10)
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return Db("sqlite", conn)
|
||||||
|
if backend == "mysql":
|
||||||
|
return connect_mysql(raw, dict_rows=False)
|
||||||
|
if backend == "pgsql":
|
||||||
|
return connect_pgsql(raw)
|
||||||
|
raise ValueError(f"unsupported target backend: {backend}")
|
||||||
|
|
||||||
|
|
||||||
|
def row_first(row: Any) -> Any:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if isinstance(row, Mapping):
|
||||||
|
return next(iter(row.values()))
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def row_tuple(row: Any) -> tuple[Any, ...]:
|
||||||
|
if isinstance(row, Mapping):
|
||||||
|
return tuple(row.values())
|
||||||
|
return tuple(row)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_user(value: str) -> str:
|
||||||
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe(values: Iterable[str]) -> list[str]:
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in values:
|
||||||
|
value = str(raw).strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
key = value.lower()
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def first_nonempty(*values: str) -> str:
|
||||||
|
for value in values:
|
||||||
|
if value and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def row_mapping(row: Any) -> Mapping[str, Any]:
|
||||||
|
if isinstance(row, Mapping):
|
||||||
|
return row
|
||||||
|
if isinstance(row, sqlite3.Row):
|
||||||
|
return {key: row[key] for key in row.keys()}
|
||||||
|
raise TypeError("Roundcube query did not return named columns")
|
||||||
|
|
||||||
|
|
||||||
|
def row_text(row: Mapping[str, Any], name: str) -> str:
|
||||||
|
value = row.get(name)
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.decode("utf-8", errors="replace")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def row_int(row: Mapping[str, Any], name: str) -> int | None:
|
||||||
|
value = row.get(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def unescape_vcard(value: str) -> str:
|
||||||
|
return (
|
||||||
|
value.replace("\\n", "\n")
|
||||||
|
.replace("\\N", "\n")
|
||||||
|
.replace("\\,", ",")
|
||||||
|
.replace("\\;", ";")
|
||||||
|
.replace("\\\\", "\\")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vcard(raw: str) -> VCard:
|
||||||
|
out = VCard()
|
||||||
|
if not raw.strip():
|
||||||
|
return out
|
||||||
|
unfolded: list[str] = []
|
||||||
|
for line in raw.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
if line.startswith((" ", "\t")) and unfolded:
|
||||||
|
unfolded[-1] += line.lstrip()
|
||||||
|
else:
|
||||||
|
unfolded.append(line)
|
||||||
|
for line in unfolded:
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
left, raw_value = line.split(":", 1)
|
||||||
|
prop = left.split(";", 1)[0].rsplit(".", 1)[-1].upper()
|
||||||
|
value = unescape_vcard(raw_value)
|
||||||
|
if prop == "FN" and not out.name:
|
||||||
|
out.name = value
|
||||||
|
elif prop == "EMAIL":
|
||||||
|
out.emails.append(value)
|
||||||
|
elif prop == "TEL":
|
||||||
|
out.phones.append(value)
|
||||||
|
elif prop == "ADR":
|
||||||
|
address = ", ".join(part.strip() for part in value.split(";") if part.strip())
|
||||||
|
if address:
|
||||||
|
out.addresses.append(address)
|
||||||
|
elif prop == "ORG" and not out.company:
|
||||||
|
out.company = " / ".join(part.strip() for part in value.split(";") if part.strip())
|
||||||
|
elif prop == "NOTE" and not out.notes:
|
||||||
|
out.notes = value
|
||||||
|
elif prop == "TITLE" and not out.job_title:
|
||||||
|
out.job_title = value
|
||||||
|
elif prop == "URL" and not out.website:
|
||||||
|
out.website = value
|
||||||
|
elif prop == "BDAY" and not out.birthday:
|
||||||
|
out.birthday = value
|
||||||
|
out.emails = dedupe(out.emails)
|
||||||
|
out.phones = dedupe(out.phones)
|
||||||
|
out.addresses = dedupe(out.addresses)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def email_candidates(raw: str) -> list[str]:
|
||||||
|
return dedupe(match.group(0) for match in EMAIL_RE.finditer(raw or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def stable_contact_id(roundcube_user: str, source_id: str, email_addr: str) -> str:
|
||||||
|
stable_key = source_id.strip() or email_addr
|
||||||
|
digest = hashlib.sha256(f"{roundcube_user}:{stable_key}".encode("utf-8")).hexdigest()
|
||||||
|
return f"rc-{digest[:32]}"
|
||||||
|
|
||||||
|
|
||||||
|
def plain_to_html(value: str) -> str:
|
||||||
|
return html.escape(value, quote=True).replace("\r\n", "<br>").replace("\n", "<br>")
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_plain(value: str) -> str:
|
||||||
|
text = BREAK_RE.sub("\n", value)
|
||||||
|
text = TAG_RE.sub("", text)
|
||||||
|
return html.unescape(text.replace(" ", " ")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def source_table(prefix: str, name: str) -> str:
|
||||||
|
validate_identifier(prefix, "Roundcube table prefix", allow_empty=True)
|
||||||
|
validate_identifier(name, "Roundcube table name")
|
||||||
|
return f"`{prefix}{name}`"
|
||||||
|
|
||||||
|
|
||||||
|
def load_roundcube_users(source: Db, prefix: str) -> list[str]:
|
||||||
|
table = source_table(prefix, "users")
|
||||||
|
rows = source.fetchall(
|
||||||
|
f"SELECT username FROM {table} WHERE username IS NOT NULL AND username<>'' ORDER BY user_id"
|
||||||
|
)
|
||||||
|
return [str(row_first(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def load_roundcube_data(source: Db, prefix: str, username: str) -> tuple[list[Contact], list[Identity], int]:
|
||||||
|
users = source_table(prefix, "users")
|
||||||
|
contacts_table = source_table(prefix, "contacts")
|
||||||
|
identities_table = source_table(prefix, "identities")
|
||||||
|
ph = source.placeholder
|
||||||
|
|
||||||
|
row = source.fetchone(f"SELECT user_id FROM {users} WHERE username={ph} LIMIT 1", [username])
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"Roundcube user not found: {username}")
|
||||||
|
user_id = row_first(row)
|
||||||
|
|
||||||
|
contact_rows = source.fetchall(f"SELECT * FROM {contacts_table} WHERE user_id={ph}", [user_id])
|
||||||
|
contacts: list[Contact] = []
|
||||||
|
skipped_contacts = 0
|
||||||
|
for raw_row in contact_rows:
|
||||||
|
row_map = row_mapping(raw_row)
|
||||||
|
if row_int(row_map, "del") not in (None, 0):
|
||||||
|
continue
|
||||||
|
vcard = parse_vcard(row_text(row_map, "vcard"))
|
||||||
|
emails = list(vcard.emails)
|
||||||
|
emails.extend(email_candidates(row_text(row_map, "email")))
|
||||||
|
emails = dedupe(email_candidates(" ".join(emails)))
|
||||||
|
if not emails:
|
||||||
|
skipped_contacts += 1
|
||||||
|
continue
|
||||||
|
primary_email = emails[0]
|
||||||
|
|
||||||
|
full_name = " ".join(
|
||||||
|
part for part in [row_text(row_map, "firstname"), row_text(row_map, "surname")] if part.strip()
|
||||||
|
)
|
||||||
|
name = first_nonempty(vcard.name, row_text(row_map, "name"), full_name, primary_email)
|
||||||
|
company = first_nonempty(vcard.company, row_text(row_map, "organization"), row_text(row_map, "company"))
|
||||||
|
notes = first_nonempty(vcard.notes, row_text(row_map, "notes"))
|
||||||
|
job_title = first_nonempty(vcard.job_title, row_text(row_map, "job_title"), row_text(row_map, "title"))
|
||||||
|
website = first_nonempty(vcard.website, row_text(row_map, "website"))
|
||||||
|
birthday = first_nonempty(vcard.birthday, row_text(row_map, "birthday"))
|
||||||
|
|
||||||
|
phones = list(vcard.phones)
|
||||||
|
direct_phone = row_text(row_map, "phone")
|
||||||
|
if direct_phone.strip():
|
||||||
|
phones.append(direct_phone)
|
||||||
|
phones = dedupe(phones)
|
||||||
|
|
||||||
|
addresses = list(vcard.addresses)
|
||||||
|
direct_address = row_text(row_map, "address")
|
||||||
|
if direct_address.strip():
|
||||||
|
addresses.append(direct_address)
|
||||||
|
addresses = dedupe(addresses)
|
||||||
|
|
||||||
|
source_id = first_nonempty(row_text(row_map, "contact_id"), primary_email)
|
||||||
|
contacts.append(
|
||||||
|
Contact(
|
||||||
|
id=stable_contact_id(username, source_id, primary_email),
|
||||||
|
email=primary_email,
|
||||||
|
name=name,
|
||||||
|
company=company,
|
||||||
|
notes=notes,
|
||||||
|
phone=phones[0] if phones else "",
|
||||||
|
job_title=job_title,
|
||||||
|
address=addresses[0] if addresses else "",
|
||||||
|
website=website,
|
||||||
|
birthday=birthday,
|
||||||
|
emails_json=json.dumps(emails[1:], ensure_ascii=False, separators=(",", ":")),
|
||||||
|
phones_json=json.dumps(phones[1:], ensure_ascii=False, separators=(",", ":")),
|
||||||
|
addresses_json=json.dumps(addresses[1:], ensure_ascii=False, separators=(",", ":")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
identity_rows = source.fetchall(f"SELECT * FROM {identities_table} WHERE user_id={ph}", [user_id])
|
||||||
|
identities: list[Identity] = []
|
||||||
|
for raw_row in identity_rows:
|
||||||
|
row_map = row_mapping(raw_row)
|
||||||
|
if row_int(row_map, "del") not in (None, 0):
|
||||||
|
continue
|
||||||
|
candidates = email_candidates(row_text(row_map, "email"))
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
signature = row_text(row_map, "signature")
|
||||||
|
html_signature = (row_int(row_map, "html_signature") or 0) != 0
|
||||||
|
signature_html, signature_text = (
|
||||||
|
(signature, html_to_plain(signature))
|
||||||
|
if html_signature
|
||||||
|
else (plain_to_html(signature), signature)
|
||||||
|
)
|
||||||
|
identities.append(
|
||||||
|
Identity(
|
||||||
|
display_name=row_text(row_map, "name"),
|
||||||
|
email=candidates[0],
|
||||||
|
signature_html=signature_html,
|
||||||
|
signature_text=signature_text,
|
||||||
|
is_default=(row_int(row_map, "standard") or 0) != 0,
|
||||||
|
reply_to=first_nonempty(row_text(row_map, "reply-to"), row_text(row_map, "reply_to")),
|
||||||
|
bcc=row_text(row_map, "bcc"),
|
||||||
|
organization=row_text(row_map, "organization"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return contacts, identities, skipped_contacts
|
||||||
|
|
||||||
|
|
||||||
|
def target_prefix_for_hash(target: Db, user_hash: str) -> str | None:
|
||||||
|
ph = target.placeholder
|
||||||
|
row = target.fetchone(
|
||||||
|
f"SELECT table_prefix FROM rustbox_accounts WHERE user_hash={ph}", [user_hash]
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
prefix = str(row_first(row))
|
||||||
|
validate_identifier(prefix, "RustBox table prefix")
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
|
||||||
|
def target_account_for_login(target: Db, login: str) -> tuple[str, str, str] | None:
|
||||||
|
exact_hash = hash_user(login)
|
||||||
|
prefix = target_prefix_for_hash(target, exact_hash)
|
||||||
|
if prefix is not None:
|
||||||
|
return exact_hash, prefix, login
|
||||||
|
|
||||||
|
ph = target.placeholder
|
||||||
|
row = target.fetchone(
|
||||||
|
"SELECT a.user_hash, a.table_prefix, s.email "
|
||||||
|
"FROM sessions s JOIN rustbox_accounts a ON a.user_hash=s.user_hash "
|
||||||
|
f"WHERE lower(s.email)=lower({ph}) ORDER BY s.last_accessed_unix DESC LIMIT 1",
|
||||||
|
[login],
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
user_hash, prefix, email_addr = row_tuple(row)[:3]
|
||||||
|
validate_identifier(str(prefix), "RustBox table prefix")
|
||||||
|
return str(user_hash), str(prefix), str(email_addr)
|
||||||
|
|
||||||
|
|
||||||
|
def now_expr(backend: str) -> str:
|
||||||
|
if backend == "sqlite":
|
||||||
|
return "datetime('now')"
|
||||||
|
if backend == "mysql":
|
||||||
|
return "CAST(CURRENT_TIMESTAMP AS CHAR)"
|
||||||
|
return "CURRENT_TIMESTAMP::text"
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_contact(target: Db, prefix: str, contact: Contact) -> None:
|
||||||
|
table = target.ident(f"{prefix}contacts")
|
||||||
|
ph = target.placeholder
|
||||||
|
existing = target.fetchone(
|
||||||
|
f"SELECT id FROM {table} WHERE lower(email)=lower({ph}) ORDER BY created_at LIMIT 1",
|
||||||
|
[contact.email],
|
||||||
|
)
|
||||||
|
values = [
|
||||||
|
contact.email,
|
||||||
|
contact.name,
|
||||||
|
contact.company,
|
||||||
|
contact.notes,
|
||||||
|
contact.phone,
|
||||||
|
contact.job_title,
|
||||||
|
contact.address,
|
||||||
|
contact.website,
|
||||||
|
contact.birthday,
|
||||||
|
contact.emails_json,
|
||||||
|
contact.phones_json,
|
||||||
|
contact.addresses_json,
|
||||||
|
]
|
||||||
|
if existing is not None:
|
||||||
|
contact_id = row_first(existing)
|
||||||
|
assigns = ",".join(
|
||||||
|
f"{column}={ph}"
|
||||||
|
for column in (
|
||||||
|
"email", "name", "company", "notes", "phone", "job_title",
|
||||||
|
"address", "website", "birthday", "emails_json", "phones_json", "addresses_json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target.execute(
|
||||||
|
f"UPDATE {table} SET {assigns},source='roundcube',updated_at={now_expr(target.backend)} WHERE id={ph}",
|
||||||
|
[*values, contact_id],
|
||||||
|
).close()
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = (
|
||||||
|
"id,email,name,company,notes,phone,job_title,address,website,birthday,"
|
||||||
|
"emails_json,phones_json,addresses_json,is_favorite,contact_count,source"
|
||||||
|
)
|
||||||
|
placeholders = ",".join([ph] * 13)
|
||||||
|
target.execute(
|
||||||
|
f"INSERT INTO {table} ({columns}) VALUES ({placeholders},0,0,'roundcube')",
|
||||||
|
[contact.id, *values],
|
||||||
|
).close()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_identity(target: Db, prefix: str, identity: Identity) -> None:
|
||||||
|
table = target.ident(f"{prefix}identities")
|
||||||
|
ph = target.placeholder
|
||||||
|
if identity.is_default:
|
||||||
|
target.execute(f"UPDATE {table} SET is_default=0 WHERE is_default<>0").close()
|
||||||
|
|
||||||
|
existing = target.fetchone(
|
||||||
|
f"SELECT id FROM {table} WHERE email={ph} AND display_name={ph} AND organization={ph} ORDER BY id LIMIT 1",
|
||||||
|
[identity.email, identity.display_name, identity.organization],
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
existing = target.fetchone(
|
||||||
|
f"SELECT id FROM {table} WHERE email={ph} AND display_name='' AND organization='' "
|
||||||
|
"AND signature_html='' AND signature_text='' ORDER BY id LIMIT 1",
|
||||||
|
[identity.email],
|
||||||
|
)
|
||||||
|
|
||||||
|
values = [
|
||||||
|
identity.display_name,
|
||||||
|
identity.email,
|
||||||
|
identity.signature_html,
|
||||||
|
1 if identity.is_default else 0,
|
||||||
|
identity.reply_to,
|
||||||
|
identity.bcc,
|
||||||
|
identity.organization,
|
||||||
|
identity.signature_text,
|
||||||
|
]
|
||||||
|
if existing is not None:
|
||||||
|
identity_id = row_first(existing)
|
||||||
|
assignments = ",".join(
|
||||||
|
f"{column}={ph}"
|
||||||
|
for column in (
|
||||||
|
"display_name", "email", "signature_html", "is_default", "reply_to", "bcc",
|
||||||
|
"organization", "signature_text",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target.execute(
|
||||||
|
f"UPDATE {table} SET {assignments},updated_at={now_expr(target.backend)} WHERE id={ph}",
|
||||||
|
[*values, identity_id],
|
||||||
|
).close()
|
||||||
|
return
|
||||||
|
|
||||||
|
placeholders = ",".join([ph] * 8)
|
||||||
|
target.execute(
|
||||||
|
f"INSERT INTO {table} "
|
||||||
|
"(display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,"
|
||||||
|
"signature_new,signature_reply,signature_forward,signature_position) "
|
||||||
|
f"VALUES ({placeholders},1,1,1,'above')",
|
||||||
|
values,
|
||||||
|
).close()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_one(
|
||||||
|
source: Db,
|
||||||
|
target: Db,
|
||||||
|
roundcube_prefix: str,
|
||||||
|
roundcube_user: str,
|
||||||
|
rustbox_user: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
user_hash_override: str | None = None,
|
||||||
|
target_prefix_override: str | None = None,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
contacts, identities, skipped_contacts = load_roundcube_data(
|
||||||
|
source, roundcube_prefix, roundcube_user
|
||||||
|
)
|
||||||
|
|
||||||
|
if target_prefix_override:
|
||||||
|
validate_identifier(target_prefix_override, "RustBox table prefix")
|
||||||
|
prefix = target_prefix_override
|
||||||
|
else:
|
||||||
|
user_hash = user_hash_override or hash_user(rustbox_user)
|
||||||
|
prefix = target_prefix_for_hash(target, user_hash)
|
||||||
|
if prefix is None:
|
||||||
|
match = target_account_for_login(target, rustbox_user)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"RustBox account not provisioned for {rustbox_user!r}; log in once first"
|
||||||
|
)
|
||||||
|
_, prefix, _ = match
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{roundcube_user} -> {rustbox_user}: {len(contacts)} contact(s), "
|
||||||
|
f"{len(identities)} identity/identities, {skipped_contacts} contact(s) skipped without email"
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
return len(contacts), len(identities), skipped_contacts
|
||||||
|
|
||||||
|
try:
|
||||||
|
for contact in contacts:
|
||||||
|
upsert_contact(target, prefix, contact)
|
||||||
|
for identity in identities:
|
||||||
|
upsert_identity(target, prefix, identity)
|
||||||
|
target.commit()
|
||||||
|
except Exception:
|
||||||
|
target.rollback()
|
||||||
|
raise
|
||||||
|
return len(contacts), len(identities), skipped_contacts
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace) -> int:
|
||||||
|
validate_identifier(args.roundcube_prefix, "Roundcube table prefix", allow_empty=True)
|
||||||
|
if args.target_prefix:
|
||||||
|
validate_identifier(args.target_prefix, "RustBox table prefix")
|
||||||
|
|
||||||
|
all_users = args.all_users or not args.roundcube_account
|
||||||
|
if all_users and any([args.rustbox_user, args.user_hash, args.target_prefix]):
|
||||||
|
raise ValueError(
|
||||||
|
"--rustbox-user, --user-hash and --target-prefix are only valid with --roundcube-account"
|
||||||
|
)
|
||||||
|
|
||||||
|
config_path, _config, backend, target_url = resolve_target(args)
|
||||||
|
roundcube_url = build_roundcube_url(args)
|
||||||
|
|
||||||
|
print(f"RustBox config: {config_path}")
|
||||||
|
print(f"RustBox target backend: {backend}")
|
||||||
|
if backend == "sqlite":
|
||||||
|
print(f"RustBox target database: {target_url}")
|
||||||
|
print(f"Mode: {'all users' if all_users else 'single user'}{' (dry-run)' if args.dry_run else ''}")
|
||||||
|
|
||||||
|
source = connect_mysql(roundcube_url, dict_rows=True)
|
||||||
|
target = connect_target(backend, target_url)
|
||||||
|
try:
|
||||||
|
# Fail early with a clear error if this is not a RustBox database.
|
||||||
|
target.fetchall("SELECT user_hash, table_prefix FROM rustbox_accounts LIMIT 1")
|
||||||
|
|
||||||
|
if not all_users:
|
||||||
|
roundcube_user = args.roundcube_account
|
||||||
|
assert roundcube_user is not None
|
||||||
|
rustbox_user = args.rustbox_user or roundcube_user
|
||||||
|
migrate_one(
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
args.roundcube_prefix,
|
||||||
|
roundcube_user,
|
||||||
|
rustbox_user,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
user_hash_override=args.user_hash,
|
||||||
|
target_prefix_override=args.target_prefix,
|
||||||
|
)
|
||||||
|
print("Dry run complete." if args.dry_run else "Migration complete.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
users = load_roundcube_users(source, args.roundcube_prefix)
|
||||||
|
print(f"Roundcube: found {len(users)} user(s).")
|
||||||
|
migrated = skipped = failed = 0
|
||||||
|
total_contacts = total_identities = total_skipped_contacts = 0
|
||||||
|
|
||||||
|
for roundcube_user in users:
|
||||||
|
try:
|
||||||
|
match = target_account_for_login(target, roundcube_user)
|
||||||
|
if match is None:
|
||||||
|
skipped += 1
|
||||||
|
print(
|
||||||
|
f"SKIP {roundcube_user}: matching RustBox account is not provisioned",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
_, prefix, rustbox_user = match
|
||||||
|
counts = migrate_one(
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
args.roundcube_prefix,
|
||||||
|
roundcube_user,
|
||||||
|
rustbox_user,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
target_prefix_override=prefix,
|
||||||
|
)
|
||||||
|
migrated += 1
|
||||||
|
total_contacts += counts[0]
|
||||||
|
total_identities += counts[1]
|
||||||
|
total_skipped_contacts += counts[2]
|
||||||
|
except Exception as exc:
|
||||||
|
target.rollback()
|
||||||
|
failed += 1
|
||||||
|
print(f"FAIL {roundcube_user}: {exc}", file=sys.stderr)
|
||||||
|
if args.fail_fast:
|
||||||
|
raise
|
||||||
|
|
||||||
|
label = "Dry run complete" if args.dry_run else "Migration complete"
|
||||||
|
print(
|
||||||
|
f"{label}: {migrated} user(s) processed, {skipped} skipped without RustBox account, "
|
||||||
|
f"{failed} failed; {total_contacts} contact(s), {total_identities} identity/identities, "
|
||||||
|
f"{total_skipped_contacts} contact(s) skipped without email."
|
||||||
|
)
|
||||||
|
return 2 if failed else 0
|
||||||
|
finally:
|
||||||
|
target.close()
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parser().parse_args()
|
||||||
|
try:
|
||||||
|
return run(args)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Migration interrupted.", file=sys.stderr)
|
||||||
|
return 130
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Roundcube migration failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
PyMySQL>=1.1,<2
|
||||||
|
# Only required when the RustBox target uses PostgreSQL:
|
||||||
|
psycopg[binary]>=3.1,<4
|
||||||
@@ -185,11 +185,16 @@ label>span,
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-row input {
|
.checkbox-row input[type="checkbox"] {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
|
min-width: 15px;
|
||||||
|
max-width: 15px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
flex: 0 0 15px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-row span {
|
.checkbox-row span {
|
||||||
|
|||||||
@@ -2304,6 +2304,18 @@ kbd {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pane-background-control input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
min-width: 15px;
|
||||||
|
max-width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 0 0 15px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width:820px) {
|
@media (max-width:820px) {
|
||||||
.pane-background-grid {
|
.pane-background-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"unavailable": "Unavailable",
|
"unavailable": "Unavailable",
|
||||||
"show_full_addresses": "Show full addresses",
|
"show_full_addresses": "Show full addresses",
|
||||||
"collapse_addresses": "Collapse addresses",
|
"collapse_addresses": "Collapse addresses",
|
||||||
"fixed_interface_style_note": "fixed interface style. Theme still switches light/dark; choose RustBox (custom) to edit colors, spacing and geometry.",
|
"fixed_interface_style_note": "predefined interface style. Enable preset editing below to create a customizable copy on the first change.",
|
||||||
"custom_interface_style_note": "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below.",
|
"custom_interface_style_note": "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below.",
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
"reply_all": "Reply all",
|
"reply_all": "Reply all",
|
||||||
|
|||||||
@@ -601,8 +601,8 @@
|
|||||||
"Draft saved": "Szkic zapisany",
|
"Draft saved": "Szkic zapisany",
|
||||||
"Uploading files…": "Wysyłanie plików…",
|
"Uploading files…": "Wysyłanie plików…",
|
||||||
"Upload complete": "Wysyłanie zakończone",
|
"Upload complete": "Wysyłanie zakończone",
|
||||||
"Allow UI scaling for predefined style": "Zezwól na skalowanie interfejsu dla gotowego stylu",
|
"Allow editing predefined style": "Zezwól na edycję gotowego stylu",
|
||||||
"When enabled, the UI size control can override the scale defined by the selected preset.": "Po włączeniu ustawienie rozmiaru interfejsu może nadpisać skalę wybranego stylu.",
|
"When enabled, the first appearance change creates and saves a RustBox custom copy of the selected preset.": "Po włączeniu pierwsza zmiana wyglądu utworzy i zapisze edytowalną kopię wybranego stylu jako RustBox custom.",
|
||||||
"Maximum attachment size": "Maksymalny rozmiar załącznika",
|
"Maximum attachment size": "Maksymalny rozmiar załącznika",
|
||||||
"Server-wide limit set with": "Globalny limit serwera ustawiany przez",
|
"Server-wide limit set with": "Globalny limit serwera ustawiany przez",
|
||||||
"in rustbox.toml.": "w rustbox.toml.",
|
"in rustbox.toml.": "w rustbox.toml.",
|
||||||
@@ -631,7 +631,7 @@
|
|||||||
"Reddit inspired": "Inspirowany Reddit",
|
"Reddit inspired": "Inspirowany Reddit",
|
||||||
"Notion inspired": "Inspirowany Notion"
|
"Notion inspired": "Inspirowany Notion"
|
||||||
},
|
},
|
||||||
"fixed_interface_style_note": "stały styl interfejsu. Motyw nadal przełącza jasny/ciemny; wybierz RustBox (niestandardowy), aby edytować kolory, odstępy i geometrię.",
|
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję presetu poniżej, aby przy pierwszej zmianie utworzyć jego edytowalną kopię.",
|
||||||
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
|
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
|
||||||
"reply": "Odpowiedz",
|
"reply": "Odpowiedz",
|
||||||
"reply_all": "Odpowiedz wszystkim",
|
"reply_all": "Odpowiedz wszystkim",
|
||||||
|
|||||||
+7
-5
@@ -611,9 +611,9 @@
|
|||||||
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
|
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
|
||||||
spacing, panes and typography.</small></label>
|
spacing, panes and typography.</small></label>
|
||||||
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input
|
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input
|
||||||
id="pref-preset-scale-unlocked" type="checkbox"><span>Allow UI scaling for predefined
|
id="pref-preset-scale-unlocked" type="checkbox"><span>Allow editing predefined style</span><small
|
||||||
style</span><small class="muted">When enabled, the UI size control can override the scale defined by
|
class="muted">When enabled, the first appearance change creates and saves a RustBox custom copy of the
|
||||||
the selected preset.</small></label>
|
selected preset.</small></label>
|
||||||
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset">
|
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset">
|
||||||
<option value="sage">Sage</option>
|
<option value="sage">Sage</option>
|
||||||
<option value="blue">Blue</option>
|
<option value="blue">Blue</option>
|
||||||
@@ -886,8 +886,10 @@
|
|||||||
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
|
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
|
||||||
spellcheck while composing</span></label>
|
spellcheck while composing</span></label>
|
||||||
<div class="compose-limit-summary" aria-label="Compose size limits">
|
<div class="compose-limit-summary" aria-label="Compose size limits">
|
||||||
<span>Maximum attachment size: <strong id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</strong></span>
|
<span>Maximum attachment size: <strong id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__
|
||||||
<span>Maximum message size: <strong id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__ MiB</strong></span>
|
MiB</strong></span>
|
||||||
|
<span>Maximum message size: <strong id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__
|
||||||
|
MiB</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
|
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
|
||||||
|
|||||||
@@ -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",
|
compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background",
|
||||||
};
|
};
|
||||||
const preferenceTimers = new Map();
|
const preferenceTimers = new Map();
|
||||||
|
let presetPromotionTimer = null;
|
||||||
|
let presetPromotionInFlight = false;
|
||||||
|
let presetPromotionNeedsAnotherSave = false;
|
||||||
|
let presetPromotionActive = false;
|
||||||
|
|
||||||
const interfaceStyleProfiles = {
|
const interfaceStyleProfiles = {
|
||||||
"roundcube-classic": {
|
"roundcube-classic": {
|
||||||
@@ -188,7 +192,7 @@ function applyPreferences() {
|
|||||||
const p = state.prefs;
|
const p = state.prefs;
|
||||||
const fixedStyle = activeInterfaceStyle(p);
|
const fixedStyle = activeInterfaceStyle(p);
|
||||||
const visual = fixedStyle
|
const visual = fixedStyle
|
||||||
? { ...p, ...fixedStyle.preferences, scale: p.presetScaleUnlocked ? p.scale : fixedStyle.preferences.scale }
|
? { ...p, ...fixedStyle.preferences }
|
||||||
: p;
|
: p;
|
||||||
root.dataset.theme = resolveTheme(p.theme);
|
root.dataset.theme = resolveTheme(p.theme);
|
||||||
root.dataset.interfaceSkin = resolvedInterfaceSkin(p);
|
root.dataset.interfaceSkin = resolvedInterfaceSkin(p);
|
||||||
@@ -247,10 +251,19 @@ function applyPreferences() {
|
|||||||
root.style.setProperty("--radius-control", `${visual.borderRadius}px`);
|
root.style.setProperty("--radius-control", `${visual.borderRadius}px`);
|
||||||
root.style.setProperty("--radius-panel", `${Math.min(visual.borderRadius + 1, 13)}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 = {
|
const values = {
|
||||||
"#pref-theme": p.theme, "#pref-interface-skin": p.interfaceSkin, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale,
|
"#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-language": p.language, "#pref-color-preset": fixedStyle ? "custom" : p.colorPreset, "#pref-accent": displayedAccent,
|
||||||
"#pref-background": p.backgroundColor, "#pref-panel": p.panelColor, "#pref-text-color": p.textColor,
|
"#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-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-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,
|
"#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; });
|
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) };
|
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); });
|
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; });
|
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);
|
const customizationLocked = Boolean(fixedStyle);
|
||||||
[
|
[
|
||||||
"#pref-color-preset", "#pref-accent", "#pref-background", "#pref-panel", "#pref-text-color",
|
"#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-selection-color", "#pref-unread-weight", "#pref-unread-badge", "#pref-scrollbar-density",
|
||||||
"#pref-compact-headers", "#pref-show-borders", "#pref-monochrome",
|
"#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",
|
"#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");
|
const scaleControl = $("#pref-scale");
|
||||||
if (scaleControl) scaleControl.disabled = customizationLocked && !p.presetScaleUnlocked;
|
if (scaleControl) scaleControl.disabled = customizationLocked && !p.presetScaleUnlocked;
|
||||||
const scaleUnlock = $("#pref-preset-scale-unlocked");
|
const scaleUnlock = $("#pref-preset-scale-unlocked");
|
||||||
@@ -284,7 +297,7 @@ function applyPreferences() {
|
|||||||
if (skinNote) {
|
if (skinNote) {
|
||||||
const styleLabel = state.prefs.language === "pl" ? (state.translations._text?.[fixedStyle?.label] || fixedStyle?.label) : fixedStyle?.label;
|
const styleLabel = state.prefs.language === "pl" ? (state.translations._text?.[fixedStyle?.label] || fixedStyle?.label) : fixedStyle?.label;
|
||||||
skinNote.textContent = fixedStyle
|
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.");
|
: 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) {
|
function savePreference(key, value) {
|
||||||
|
if (promotePresetToCustom(key, value)) return;
|
||||||
state.prefs[key] = value;
|
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";
|
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||||
applyPreferences();
|
applyPreferences();
|
||||||
if (key === "language") loadTranslations(value);
|
if (key === "language") loadTranslations(value);
|
||||||
|
|||||||
@@ -728,6 +728,13 @@ function attachmentContentUrl(folder, uid, attachment) {
|
|||||||
return `${path}${path.includes("?") ? "&" : "?"}account_id=${encodeURIComponent(state.accountId)}`;
|
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) {
|
function attachmentPreviewLimitBytes(attachment) {
|
||||||
const mime = attachmentMime(attachment);
|
const mime = attachmentMime(attachment);
|
||||||
const filename = String(attachment?.filename || "").toLowerCase();
|
const filename = String(attachment?.filename || "").toLowerCase();
|
||||||
@@ -768,6 +775,13 @@ async function openAttachment(folder, uid, attachment) {
|
|||||||
const declaredMime = attachmentMime(attachment);
|
const declaredMime = attachmentMime(attachment);
|
||||||
const filename = String(attachment?.filename || "").toLowerCase();
|
const filename = String(attachment?.filename || "").toLowerCase();
|
||||||
const isPdf = declaredMime === "application/pdf" || filename.endsWith(".pdf");
|
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 blob = isPdf ? null : await fetchAttachmentBlob(folder, uid, attachment);
|
||||||
const mime = isPdf ? "application/pdf" : attachmentMime(attachment, blob);
|
const mime = isPdf ? "application/pdf" : attachmentMime(attachment, blob);
|
||||||
const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);
|
const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);
|
||||||
|
|||||||
Reference in new issue
Block a user