v0.2.10
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 09:37:35 +01:00
1 parent 02d0b13ec1
commit db0d230d3b
12 files changed
+1146 -17

No files matched your search

+1
View File
@@ -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
+48
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
exec cargo run --bin roundcube-migrate -- "$@"
+942
View File
@@ -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("&nbsp;", " ")).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
+6 -1
View File
@@ -185,11 +185,16 @@ label>span,
gap: 8px;
}
.checkbox-row input {
.checkbox-row 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);
}
.checkbox-row span {
+12
View File
@@ -2304,6 +2304,18 @@ kbd {
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) {
.pane-background-grid {
grid-template-columns: 1fr;
+1 -1
View File
@@ -46,7 +46,7 @@
"unavailable": "Unavailable",
"show_full_addresses": "Show full 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.",
"reply": "Reply",
"reply_all": "Reply all",
+3 -3
View File
@@ -601,8 +601,8 @@
"Draft saved": "Szkic zapisany",
"Uploading files…": "Wysyłanie plików…",
"Upload complete": "Wysyłanie zakończone",
"Allow UI scaling for predefined style": "Zezwól na skalowanie interfejsu dla 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.",
"Allow editing predefined style": "Zezwól na edycję gotowego 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",
"Server-wide limit set with": "Globalny limit serwera ustawiany przez",
"in rustbox.toml.": "w rustbox.toml.",
@@ -631,7 +631,7 @@
"Reddit inspired": "Inspirowany Reddit",
"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.",
"reply": "Odpowiedz",
"reply_all": "Odpowiedz wszystkim",
+7 -5
View File
@@ -611,9 +611,9 @@
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
spacing, panes and typography.</small></label>
<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
style</span><small class="muted">When enabled, the UI size control can override the scale defined by
the selected preset.</small></label>
id="pref-preset-scale-unlocked" type="checkbox"><span>Allow editing predefined style</span><small
class="muted">When enabled, the first appearance change creates and saves a RustBox custom copy of the
selected preset.</small></label>
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset">
<option value="sage">Sage</option>
<option value="blue">Blue</option>
@@ -886,8 +886,10 @@
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
spellcheck while composing</span></label>
<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 message size: <strong id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__ MiB</strong></span>
<span>Maximum attachment size: <strong id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__
MiB</strong></span>
<span>Maximum message size: <strong id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__
MiB</strong></span>
</div>
</div>
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
+105 -7
View File
@@ -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);
+14
View File
@@ -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);