1 parent
ecc201ef50
commit
698e3c4a1b
124 files changed
+4593
-2721
No files matched your search
@@ -1,157 +0,0 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sqlite3
|
||||
from html.parser import HTMLParser
|
||||
import tinycss2
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class IdCollector(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.ids = []
|
||||
|
||||
def handle_starttag(self, _tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
if "id" in attrs:
|
||||
self.ids.append(attrs["id"])
|
||||
|
||||
|
||||
def js_bundle_source():
|
||||
js_dir = ROOT / "src/web/js"
|
||||
names = [line.strip() for line in (js_dir / "manifest.txt").read_text().splitlines() if line.strip() and not line.lstrip().startswith("#")]
|
||||
assert len(names) >= 10
|
||||
assert len(names) == len(set(names))
|
||||
return names, "\n".join((js_dir / name).read_text() for name in names)
|
||||
|
||||
|
||||
def test_schema_and_response_templates():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
migrations = sorted(ROOT.joinpath("migrations").glob("V*.sql"))
|
||||
for migration in migrations:
|
||||
conn.executescript(migration.read_text())
|
||||
assert int(migrations[-1].name[1:4]) == 46
|
||||
display_columns = {row[1] for row in conn.execute("pragma table_info(display_preferences)")}
|
||||
response_columns = {row[1] for row in conn.execute("pragma table_info(response_templates)")}
|
||||
assert "interface_skin" in display_columns
|
||||
assert "preset_scale_unlocked" in display_columns
|
||||
assert "format" in response_columns
|
||||
conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("Text", "Hello", "text"))
|
||||
conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("HTML", "<b>Hello</b>", "html"))
|
||||
assert conn.execute("select count(*) from response_templates").fetchone()[0] == 2
|
||||
|
||||
|
||||
def test_html_contract():
|
||||
source = ROOT.joinpath("src/web/index.html").read_text()
|
||||
parser = IdCollector()
|
||||
parser.feed(source)
|
||||
duplicates = {value for value in parser.ids if parser.ids.count(value) > 1}
|
||||
assert not duplicates
|
||||
assert "folder-drawer-header" in source
|
||||
assert "#icon-arrow-left" in source
|
||||
assert "pref-interface-skin" in source
|
||||
assert "compose-response-template" in source
|
||||
assert 'data-default-theme="__RUSTBOX_DEFAULT_THEME__"' in source
|
||||
assert 'id="folder-quota"' in source
|
||||
assert 'id="folder-quota-bar"' in source
|
||||
assert 'id="reader-to"' in source and 'aria-expanded="false"' in source
|
||||
for control_id in ["message-details-menu", "message-transfer-menu", "reader-move-action", "reader-copy-action", "compose-mode"]:
|
||||
assert f'id="{control_id}"' in source
|
||||
assert "data-appearance-preset" not in source
|
||||
for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord", "github", "gitlab", "discord", "slack", "reddit", "notion"]:
|
||||
assert f'value="{value}"' in source
|
||||
|
||||
|
||||
def test_roundcube_palette_contract():
|
||||
classic_elastic = (ROOT / "src/web/css/55-roundcube-skins.css").read_text().lower()
|
||||
extra_styles = (ROOT / "src/web/css/56-interface-styles.css").read_text().lower()
|
||||
for css in [classic_elastic, extra_styles]:
|
||||
errors = [item for item in tinycss2.parse_stylesheet(css, skip_comments=False, skip_whitespace=False) if item.type == "error"]
|
||||
assert not errors
|
||||
for token in ["#f6f6f6", "#f2f2f2", "#cc3333", "#929292", "#37beff", "#2f3a3f", "#21292c", "#c5d1d3", "#4d6066", "#374549"]:
|
||||
assert token in classic_elastic
|
||||
for selector in [
|
||||
'roundcube-classic"][data-theme="light',
|
||||
'roundcube-classic"][data-theme="dark',
|
||||
'roundcube-elastic"][data-theme="light',
|
||||
'roundcube-elastic"][data-theme="dark',
|
||||
]:
|
||||
assert selector in classic_elastic
|
||||
for selector in ["roundcube-larry", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast"]:
|
||||
assert f'data-interface-skin="{selector}"' in extra_styles
|
||||
manifest = (ROOT / "src/web/css/manifest.txt").read_text()
|
||||
assert "55-roundcube-skins.css" in manifest
|
||||
assert "56-interface-styles.css" in manifest
|
||||
|
||||
|
||||
def test_modular_js_contract():
|
||||
assert not (ROOT / "src/web/app.js").exists()
|
||||
names, app = js_bundle_source()
|
||||
assert names[0] == "00-bootstrap.js"
|
||||
assert names[-1] == "95-bindings-boot.js"
|
||||
assert "interfaceStyleProfiles" in app
|
||||
assert '"roundcube-classic"' in app
|
||||
assert '"roundcube-larry"' in app
|
||||
assert '"roundcube-elastic"' in app
|
||||
assert "loadResponseTemplates" in app
|
||||
assert "insertSavedResponse" in app
|
||||
assert "loadQuota();" in app
|
||||
assert "toggleReaderAddressExpansion" in app
|
||||
assert "translateScopedUiText" in app
|
||||
assert "presetScaleUnlocked" in app
|
||||
assert "positionToolbarMenu" in app
|
||||
assert "progressToast" not in app
|
||||
assert "attachmentPreviewLimitBytes" in app
|
||||
assert 'label: "Preview"' in app
|
||||
assert "This attachment is too large for preview. Download it instead?" in app
|
||||
assert "appearancePresets" not in app
|
||||
assert "applyAppearancePreset" not in app
|
||||
assert "src/web/js/manifest.txt" in (ROOT / "README.md").read_text()
|
||||
build = (ROOT / "build.rs").read_text()
|
||||
assert 'root.join("src/web/js")' in build
|
||||
assert 'out.join("rustbox-ui.js")' in build
|
||||
assert 'include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.js"))' in (ROOT / "src/ui.rs").read_text()
|
||||
|
||||
|
||||
def test_backend_skin_contract_and_version():
|
||||
db_source = (ROOT / "src/db/display_preferences.rs").read_text()
|
||||
for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord", "github", "gitlab", "discord", "slack", "reddit", "notion"]:
|
||||
assert f'"{value}"' in db_source
|
||||
cargo = (ROOT / "Cargo.toml").read_text()
|
||||
assert 'version = "0.1.0"' in cargo
|
||||
config = (ROOT / "src/config.rs").read_text()
|
||||
assert "pub default_theme: String" in config
|
||||
assert '"system" | "dark" | "light"' in config
|
||||
ui = (ROOT / "src/ui.rs").read_text()
|
||||
assert 'replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)' in ui
|
||||
assert "V044__interface_skin.sql" in (ROOT / "src/db/pool.rs").read_text()
|
||||
assert "V045__response_template_format.sql" in (ROOT / "src/db/pool.rs").read_text()
|
||||
assert "V046__preset_scale_unlock.sql" in (ROOT / "src/db/pool.rs").read_text()
|
||||
for path in ROOT.glob("src/web/i18n/*.json"):
|
||||
json.loads(path.read_text())
|
||||
|
||||
|
||||
def test_mobile_003_contract():
|
||||
responsive = (ROOT / "src/web/css/60-responsive.css").read_text()
|
||||
assert '.sieve-builder .checkbox-row input[type="checkbox"]' in responsive
|
||||
assert ".dialog>.dialog-card" in responsive
|
||||
assert "grid-template-rows: auto minmax(0, 1fr)" in responsive
|
||||
assert ".calendar-toolbar" in responsive
|
||||
assert ".message-meta.addresses-expanded" in responsive
|
||||
pl = json.loads((ROOT / "src/web/i18n/pl.json").read_text())
|
||||
assert len(pl.get("_text", {})) >= 450
|
||||
for key in ["Rule enabled", "Stop after this rule", "Calendar", "Message headers", "Message source", "Forward as attachment", "Show headers", "Show source", "Write a message", "Preview", "Download", "Preview unavailable"]:
|
||||
assert key in pl["_text"]
|
||||
for key in ["reply", "reply_all", "forward", "message_details", "move_copy", "move_to", "copy_to"]:
|
||||
assert key in pl
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_schema_and_response_templates()
|
||||
test_html_contract()
|
||||
test_roundcube_palette_contract()
|
||||
test_modular_js_contract()
|
||||
test_backend_skin_contract_and_version()
|
||||
test_mobile_003_contract()
|
||||
print("roundcube-skin-contract: OK")
|
||||
@@ -1,32 +0,0 @@
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_all_user_sqlite_migrations_apply_on_fresh_database(tmp_path):
|
||||
db_path = tmp_path / "user.sqlite"
|
||||
conn = sqlite3.connect(db_path)
|
||||
migrations = sorted((ROOT / "migrations/sqlite").glob("V*.sql"))
|
||||
user_migrations = [m for m in migrations if m.name != "V047__sessions.sql"]
|
||||
assert len(user_migrations) == 46
|
||||
for migration in user_migrations:
|
||||
conn.executescript(migration.read_text())
|
||||
version = int(migration.name[1:4])
|
||||
conn.execute(f"PRAGMA user_version = {version}")
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == 46
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
assert {"user_meta", "folders", "messages", "drafts", "contacts"} <= tables
|
||||
assert "sessions" not in tables
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_session_sqlite_migration_is_separate_and_valid(tmp_path):
|
||||
db_path = tmp_path / "rustbox.sqlite"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript((ROOT / "migrations/sqlite/V047__sessions.sql").read_text())
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
indexes = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='index'")}
|
||||
assert "sessions" in tables
|
||||
assert {"idx_sessions_browser", "idx_sessions_user", "idx_sessions_expiry"} <= indexes
|
||||
conn.close()
|
||||
@@ -1,74 +0,0 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EXPECTED = (
|
||||
"uid, folder, message_id, in_reply_to, references_header, subject, "
|
||||
"from_address, from_name, to_addresses, cc_addresses, date, flags, "
|
||||
"size, has_attachments, snippet, reaction"
|
||||
)
|
||||
|
||||
|
||||
def read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text()
|
||||
|
||||
|
||||
def test_cached_message_projection_is_stable_for_all_sql_dialects():
|
||||
for dialect in ("sqlite", "mysql", "pgsql"):
|
||||
text = read(f"src/queries/{dialect}/queries.rs")
|
||||
m = re.search(r'pub const MSG_SELECT_COLS: &str = "([^"]+)";', text)
|
||||
assert m, dialect
|
||||
assert m.group(1) == EXPECTED, dialect
|
||||
|
||||
|
||||
def test_cached_message_row_mapping_matches_projection():
|
||||
text = read("src/db/messages.rs")
|
||||
assert "let has_attachments_int: i32 = row.get(13)?;" in text
|
||||
assert "flags: row.get(11)?" in text
|
||||
assert "size: row.get(12)?" in text
|
||||
assert "snippet: row.get(14)?" in text
|
||||
assert "reaction: row.get(15)?" in text
|
||||
|
||||
|
||||
def test_cached_message_projection_does_not_include_body_cache_columns():
|
||||
sqlite = read("src/queries/sqlite/queries.rs")
|
||||
m = re.search(r'pub const MSG_SELECT_COLS: &str = "([^"]+)";', sqlite)
|
||||
projection = m.group(1)
|
||||
for unexpected in ("date_epoch", "body_cached", "body_html", "body_text", "attachments_json", "raw_headers", "email_theme"):
|
||||
assert unexpected not in projection
|
||||
|
||||
|
||||
def test_cached_message_projection_executes_on_fresh_sqlite_schema():
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
migration_dir = ROOT / "migrations/sqlite"
|
||||
for migration in sorted(migration_dir.glob("V*.sql")):
|
||||
if migration.name.startswith("V047__sessions"):
|
||||
continue
|
||||
conn.executescript(migration.read_text())
|
||||
|
||||
conn.execute("INSERT OR IGNORE INTO folders(name) VALUES ('INBOX')")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO messages
|
||||
(uid, folder, message_id, in_reply_to, references_header, subject,
|
||||
from_address, from_name, to_addresses, cc_addresses, date, flags,
|
||||
size, has_attachments, snippet, date_epoch, reaction)
|
||||
VALUES (1, 'INBOX', 'm1', NULL, NULL, 'Subject',
|
||||
'a@example.com', 'Alice', '[]', '[]', '2026-10-30', '\\Seen',
|
||||
123, 1, 'snippet', 1761840000, 'ok')
|
||||
"""
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
f"SELECT {EXPECTED} FROM messages WHERE folder = ? AND uid = ?",
|
||||
("INBOX", 1),
|
||||
).fetchone()
|
||||
|
||||
assert len(row) == 16
|
||||
assert row[11] == "\\Seen"
|
||||
assert row[12] == 123
|
||||
assert row[13] == 1
|
||||
assert row[14] == "snippet"
|
||||
assert row[15] == "ok"
|
||||
@@ -1,91 +0,0 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "src"
|
||||
DIALECTS = ("sqlite", "mysql", "pgsql")
|
||||
|
||||
SQL_LITERAL = re.compile(
|
||||
r'(?:r#{0,8})?"\s*(?:SELECT\s+(?:\*|[A-Za-z_(])|INSERT\s+INTO|INSERT\s+OR\s+(?:IGNORE|REPLACE)\s+INTO|UPDATE\s+[A-Za-z_]|DELETE\s+FROM|PRAGMA\s+[A-Za-z_]|WITH\s+[A-Za-z_]|CREATE\s+(?:TABLE|INDEX)|ALTER\s+TABLE|DROP\s+(?:TABLE|INDEX))',
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
CONST_NAME = re.compile(r"pub const\s+([A-Z][A-Z0-9_]*)\s*:")
|
||||
MODULE = re.compile(r"^pub mod\s+([a-zA-Z0-9_]+)\s*\{", re.MULTILINE)
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
|
||||
def test_central_query_tree_exists():
|
||||
for dialect in DIALECTS:
|
||||
assert (ROOT / f"src/queries/{dialect}/queries.rs").is_file()
|
||||
assert (ROOT / f"src/queries/{dialect}/mod.rs").is_file()
|
||||
assert not (ROOT / "src/session_store/queries").exists()
|
||||
|
||||
|
||||
def test_session_store_uses_central_query_tree():
|
||||
db = read("src/session_store/database.rs")
|
||||
for dialect in DIALECTS:
|
||||
assert f"queries::{dialect}::queries::sessions::" in db
|
||||
|
||||
|
||||
def test_no_executable_sql_literals_outside_query_tree():
|
||||
offenders = []
|
||||
for path in SRC.rglob("*.rs"):
|
||||
if "queries" in path.parts:
|
||||
continue
|
||||
text = path.read_text()
|
||||
if SQL_LITERAL.search(text):
|
||||
offenders.append(str(path.relative_to(ROOT)))
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_dialect_catalogs_have_same_modules_and_query_constants():
|
||||
catalogs = {d: read(f"src/queries/{d}/queries.rs") for d in DIALECTS}
|
||||
base_modules = set(MODULE.findall(catalogs["sqlite"]))
|
||||
assert "sessions" in base_modules
|
||||
for dialect in DIALECTS[1:]:
|
||||
assert set(MODULE.findall(catalogs[dialect])) == base_modules
|
||||
|
||||
# Every dialect must expose the same named query constants/templates.
|
||||
base_consts = set(CONST_NAME.findall(catalogs["sqlite"]))
|
||||
assert len(base_consts) >= 10
|
||||
for dialect in DIALECTS[1:]:
|
||||
assert set(CONST_NAME.findall(catalogs[dialect])) == base_consts
|
||||
|
||||
|
||||
def test_backend_specific_session_placeholders_are_preserved():
|
||||
sqlite = read("src/queries/sqlite/queries.rs")
|
||||
mysql = read("src/queries/mysql/queries.rs")
|
||||
pgsql = read("src/queries/pgsql/queries.rs")
|
||||
assert 'BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = ?"' in sqlite
|
||||
assert 'BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = ?"' in mysql
|
||||
assert 'BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = $1"' in pgsql
|
||||
|
||||
|
||||
def test_dynamic_query_macros_do_not_capture_caller_locals():
|
||||
sqlite = read("src/queries/sqlite/queries.rs")
|
||||
# macro_rules identifiers are hygienic: caller-local values must arrive as metavariables.
|
||||
forbidden = [
|
||||
'WHERE id = ?{idx}',
|
||||
'ORDER BY {order_by}',
|
||||
'{where_clause}',
|
||||
'SET {set_clause}',
|
||||
'user_version = {current}',
|
||||
'user_version = {version}',
|
||||
]
|
||||
macro_section = sqlite[sqlite.index("#[macro_export]"):]
|
||||
for fragment in forbidden:
|
||||
assert fragment not in macro_section
|
||||
|
||||
calendar = read("src/db/calendar.rs")
|
||||
messages = read("src/db/messages.rs")
|
||||
notifications = read("src/db/notification_preferences.rs")
|
||||
pool = read("src/db/pool.rs")
|
||||
assert 'rustbox_query_db_calendar_004!(sets.join(", "), idx)' in calendar
|
||||
assert 'rustbox_query_db_messages_002!(&order_by)' in messages
|
||||
assert 'rustbox_query_db_messages_005!(&where_clause, param_values.len() + 1)' in messages
|
||||
assert 'rustbox_query_db_notification_preferences_001!(&set_clause, idx)' in notifications
|
||||
assert re.search(r'db_pool::set_user_version\(\s*version,?\s*\)', pool)
|
||||
assert 'refinery_schema_history' not in pool
|
||||
@@ -1,52 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def read(path):
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
def test_release_version():
|
||||
assert 'version = "0.1.0"' in read("Cargo.toml")
|
||||
assert "# RustBox 0.1.0" in read("README.md")
|
||||
|
||||
def test_realtime_contract():
|
||||
ws = read("src/realtime/ws.rs")
|
||||
js = read("src/web/js/80-settings-realtime.js")
|
||||
assert '"type": "heartbeat"' in ws
|
||||
assert "Heartbeat timeout" in js
|
||||
assert "2 ** attempt" in js
|
||||
assert "resyncRealtimeState" in js
|
||||
assert "realtimeSeenEvents" in js
|
||||
assert "realtimePollTimer" in js
|
||||
assert "realtimeRefreshEvents" in js
|
||||
|
||||
def test_upload_progress_is_not_toast_driven():
|
||||
compose = read("src/web/js/50-compose.js")
|
||||
block = compose[compose.index("async function uploadComposeFiles"):compose.index("function previewMarkdown")]
|
||||
assert "progressToast(" not in block
|
||||
assert "setComposeUploadProgress" in block
|
||||
assert "AbortController" in block
|
||||
|
||||
def test_fullscreen_reader_contract():
|
||||
html = read("src/web/index.html")
|
||||
reader = read("src/web/js/40-messages-reader.js")
|
||||
menu = read("src/web/js/60-mail-actions-search.js")
|
||||
css = read("src/web/css/30-mail.css")
|
||||
assert 'id="reader-fullscreen"' in html
|
||||
assert "Open full screen" in menu
|
||||
assert "message-fullscreen-action" in reader
|
||||
assert "reader-fullscreen" in reader
|
||||
assert "history.pushState" in reader
|
||||
assert "reader-pane.reader-fullscreen" in css
|
||||
assert ".reader-pane.reader-fullscreen .message-body" in css
|
||||
assert "max-width: none" in css
|
||||
|
||||
def test_mobile_reader_contract():
|
||||
css = read("src/web/css/30-mail.css")
|
||||
responsive = read("src/web/css/60-responsive.css")
|
||||
bindings = read("src/web/js/95-bindings-boot.js")
|
||||
assert ".toolbar-menu-popover button" in css
|
||||
assert ".attachment-item .attachment-button" in css
|
||||
assert "min-height: 34px" in responsive
|
||||
assert 'bottom: "8px"' not in bindings
|
||||
assert 'rect.bottom + 5' in bindings
|
||||
@@ -1,71 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def read(path):
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
def test_release_version_is_at_least_006():
|
||||
import re
|
||||
cargo = read("Cargo.toml")
|
||||
match = re.search(r'^version = "(\d+)\.(\d+)\.(\d+)"$', cargo, re.M)
|
||||
assert match
|
||||
version = tuple(map(int, match.groups()))
|
||||
assert version >= (0, 0, 6)
|
||||
|
||||
def test_realtime_contract():
|
||||
ws = read("src/realtime/ws.rs")
|
||||
js = read("src/web/js/80-settings-realtime.js")
|
||||
assert '"type": "heartbeat"' in ws
|
||||
assert "Heartbeat timeout" in js
|
||||
assert "2 ** attempt" in js
|
||||
assert "resyncRealtimeState" in js
|
||||
assert "realtimeSeenEvents" in js
|
||||
assert "realtimePollTimer" in js
|
||||
assert "realtimeRefreshEvents" in js
|
||||
|
||||
def test_upload_progress_is_not_toast_driven():
|
||||
compose = read("src/web/js/50-compose.js")
|
||||
block = compose[compose.index("async function uploadComposeFiles"):compose.index("function previewMarkdown")]
|
||||
assert "progressToast(" not in block
|
||||
assert "setComposeUploadProgress" in block
|
||||
assert "AbortController" in block
|
||||
|
||||
def test_fullscreen_reader_contract():
|
||||
html = read("src/web/index.html")
|
||||
reader = read("src/web/js/40-messages-reader.js")
|
||||
menu = read("src/web/js/60-mail-actions-search.js")
|
||||
css = read("src/web/css/30-mail.css")
|
||||
assert 'id="reader-fullscreen"' in html
|
||||
assert "Open full screen" in menu
|
||||
assert "message-fullscreen-action" in reader
|
||||
assert "reader-fullscreen" in reader
|
||||
assert "history.pushState" in reader
|
||||
assert "reader-pane.reader-fullscreen" in css
|
||||
assert ".reader-pane.reader-fullscreen .message-body" in css
|
||||
assert "max-width: none" in css
|
||||
|
||||
def test_mobile_reader_contract():
|
||||
css = read("src/web/css/30-mail.css")
|
||||
responsive = read("src/web/css/60-responsive.css")
|
||||
bindings = read("src/web/js/95-bindings-boot.js")
|
||||
assert ".toolbar-menu-popover button" in css
|
||||
assert ".attachment-item .attachment-button" in css
|
||||
assert "min-height: 34px" in responsive
|
||||
assert 'bottom: "8px"' not in bindings
|
||||
assert 'rect.bottom + 5' in bindings
|
||||
|
||||
def test_message_links_can_open_new_tabs_from_sandbox():
|
||||
reader = read("src/web/js/40-messages-reader.js")
|
||||
assert 'allow-popups allow-popups-to-escape-sandbox' in reader
|
||||
|
||||
|
||||
def test_extended_color_presets_contract():
|
||||
html = read("src/web/index.html")
|
||||
css = read("src/web/css/00-tokens.css")
|
||||
backend = read("src/db/display_preferences.rs")
|
||||
presets = ["emerald", "lime", "sky", "navy", "plum", "magenta", "copper", "sand"]
|
||||
for preset in presets:
|
||||
assert f'<option value="{preset}">' in html
|
||||
assert f'html[data-color-preset="{preset}"]' in css
|
||||
assert f'"{preset}"' in backend
|
||||
@@ -1,98 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def read(path):
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
def test_release_version():
|
||||
assert 'version = "0.1.0"' in read("Cargo.toml")
|
||||
assert "# RustBox 0.1.0" in read("README.md")
|
||||
|
||||
def test_realtime_contract():
|
||||
ws = read("src/realtime/ws.rs")
|
||||
js = read("src/web/js/80-settings-realtime.js")
|
||||
assert '"type": "heartbeat"' in ws
|
||||
assert "Heartbeat timeout" in js
|
||||
assert "2 ** attempt" in js
|
||||
assert "resyncRealtimeState" in js
|
||||
assert "realtimeSeenEvents" in js
|
||||
assert "realtimePollTimer" in js
|
||||
assert "realtimeRefreshEvents" in js
|
||||
|
||||
def test_upload_progress_is_not_toast_driven():
|
||||
compose = read("src/web/js/50-compose.js")
|
||||
block = compose[compose.index("async function uploadComposeFiles"):compose.index("function previewMarkdown")]
|
||||
assert "progressToast(" not in block
|
||||
assert "setComposeUploadProgress" in block
|
||||
assert "AbortController" in block
|
||||
|
||||
def test_fullscreen_reader_contract():
|
||||
html = read("src/web/index.html")
|
||||
reader = read("src/web/js/40-messages-reader.js")
|
||||
menu = read("src/web/js/60-mail-actions-search.js")
|
||||
css = read("src/web/css/30-mail.css")
|
||||
assert 'id="reader-fullscreen"' in html
|
||||
assert "Open full screen" in menu
|
||||
assert "message-fullscreen-action" in reader
|
||||
assert "reader-fullscreen" in reader
|
||||
assert "history.pushState" in reader
|
||||
assert "reader-pane.reader-fullscreen" in css
|
||||
assert ".reader-pane.reader-fullscreen .message-body" in css
|
||||
assert "max-width: none" in css
|
||||
|
||||
def test_mobile_reader_contract():
|
||||
css = read("src/web/css/30-mail.css")
|
||||
responsive = read("src/web/css/60-responsive.css")
|
||||
bindings = read("src/web/js/95-bindings-boot.js")
|
||||
assert ".toolbar-menu-popover button" in css
|
||||
assert ".attachment-item .attachment-button" in css
|
||||
assert "min-height: 34px" in responsive
|
||||
assert 'bottom: "8px"' not in bindings
|
||||
assert 'rect.bottom + 5' in bindings
|
||||
|
||||
def test_message_links_can_open_new_tabs_from_sandbox():
|
||||
reader = read("src/web/js/40-messages-reader.js")
|
||||
assert 'iframe.setAttribute("sandbox", "allow-popups allow-popups-to-escape-sandbox allow-downloads")' in reader
|
||||
|
||||
|
||||
def test_extended_color_presets_contract():
|
||||
html = read("src/web/index.html")
|
||||
css = read("src/web/css/00-tokens.css")
|
||||
backend = read("src/db/display_preferences.rs")
|
||||
presets = ["emerald", "lime", "sky", "navy", "plum", "magenta", "copper", "sand"]
|
||||
for preset in presets:
|
||||
assert f'<option value="{preset}">' in html
|
||||
assert f'html[data-color-preset="{preset}"]' in css
|
||||
assert f'"{preset}"' in backend
|
||||
|
||||
|
||||
def test_all_predefined_interface_styles_are_visible_and_wired():
|
||||
import re
|
||||
html = read("src/web/index.html")
|
||||
js = read("src/web/js/20-preferences-auth.js")
|
||||
css = read("src/web/css/55-roundcube-skins.css") + read("src/web/css/56-interface-styles.css")
|
||||
backend = read("src/db/display_preferences.rs")
|
||||
config = read("src/config.rs")
|
||||
select = re.search(r'<select id="pref-interface-skin">(.*?)</select>', html, re.S).group(1)
|
||||
visible = set(re.findall(r'<option value="([^"]+)">', select))
|
||||
expected = {
|
||||
"default", "rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic",
|
||||
"clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite",
|
||||
"high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord",
|
||||
"github", "gitlab", "discord", "slack", "reddit", "notion",
|
||||
}
|
||||
assert visible == expected
|
||||
for skin in expected - {"default", "rustbox"}:
|
||||
assert f'data-interface-skin="{skin}"' in css
|
||||
assert re.search(rf'(?:\"{re.escape(skin)}\"|\b{re.escape(skin)})\s*:', js)
|
||||
assert f'"{skin}"' in backend
|
||||
for skin in expected - {"default"}:
|
||||
assert f'"{skin}"' in config
|
||||
|
||||
|
||||
def test_new_website_inspired_styles_have_light_and_dark_palettes():
|
||||
css = read("src/web/css/56-interface-styles.css")
|
||||
for skin in ["github", "gitlab", "discord", "slack", "reddit", "notion"]:
|
||||
assert f'html[data-interface-skin="{skin}"][data-theme="light"]' in css
|
||||
assert f'html[data-interface-skin="{skin}"][data-theme="dark"]' in css
|
||||
@@ -1,123 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def read(path):
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
def test_release_version():
|
||||
assert 'version = "0.1.0"' in read('Cargo.toml')
|
||||
assert '# RustBox 0.1.0' in read('README.md')
|
||||
|
||||
def test_default_session_storage_is_sqlite_in_data_dir():
|
||||
cfg = read('src/config.rs')
|
||||
db = read('src/session_store/database.rs')
|
||||
assert '"sqlite".to_string()' in cfg
|
||||
assert 'session_backend' in cfg
|
||||
assert 'join("rustbox.sqlite")' in db
|
||||
assert 'config.data_dir' in db
|
||||
|
||||
def test_supported_sql_backends_and_query_files():
|
||||
cfg = read('src/config.rs')
|
||||
assert '"sqlite" | "mysql" | "pgsql"' in cfg
|
||||
for dialect in ['sqlite', 'mysql', 'pgsql']:
|
||||
q = ROOT / f'src/queries/{dialect}/queries.rs'
|
||||
m = ROOT / f'migrations/{dialect}/V047__sessions.sql'
|
||||
assert q.is_file(), q
|
||||
assert m.is_file(), m
|
||||
assert 'pub mod sessions' in q.read_text()
|
||||
assert 'CREATE TABLE IF NOT EXISTS sessions' in m.read_text()
|
||||
|
||||
def test_legacy_user_migrations_are_scoped_to_sqlite():
|
||||
pool = read('src/db/pool.rs')
|
||||
assert '../../migrations/sqlite/V001__initial_schema.sql' in pool
|
||||
assert (ROOT / 'migrations/sqlite/V046__preset_scale_unlock.sql').is_file()
|
||||
|
||||
|
||||
def test_migrations_are_flat_by_dialect():
|
||||
for dialect in ["sqlite", "mysql", "pgsql"]:
|
||||
root = ROOT / "migrations" / dialect
|
||||
assert root.is_dir()
|
||||
assert not any(path.is_dir() for path in root.iterdir())
|
||||
assert (ROOT / "migrations/sqlite/V001__initial_schema.sql").is_file()
|
||||
assert (ROOT / "migrations/sqlite/V047__sessions.sql").is_file()
|
||||
|
||||
def test_persisted_credentials_are_encrypted():
|
||||
crypto = read('src/session_store/crypto.rs')
|
||||
session = read('src/auth/session.rs')
|
||||
assert 'Aes256Gcm' in crypto
|
||||
assert 'password_ciphertext' in session
|
||||
assert 'crypto.encrypt(&session.password)' in session
|
||||
assert 'crypto.decrypt(&record.password_ciphertext)' in session
|
||||
|
||||
def test_cluster_shared_reads_are_used_in_request_paths():
|
||||
middleware = read('src/auth/middleware.rs')
|
||||
auth = read('src/routes/auth.rs')
|
||||
ws = read('src/realtime/ws.rs')
|
||||
assert 'get_account_session_shared' in middleware
|
||||
assert 'get_browser_accounts_shared' in auth
|
||||
assert 'get_browser_accounts_shared' in ws
|
||||
|
||||
def test_redis_direct_and_sentinel_are_supported():
|
||||
redis = read('src/session_store/redis_store.rs')
|
||||
cfg = read('src/config.rs')
|
||||
assert 'get-master-addr-by-name' in redis
|
||||
assert 'redis_url' in cfg
|
||||
assert 'redis_sentinel_nodes' in cfg
|
||||
assert 'redis_sentinel_master' in cfg
|
||||
|
||||
def test_restart_regression_test_exists():
|
||||
session = read('src/auth/session.rs')
|
||||
assert 'persistent_sqlite_session_survives_store_restart' in session
|
||||
assert 'session should survive restart' in session
|
||||
|
||||
|
||||
def test_sqlite_native_link_is_owned_only_by_rusqlite():
|
||||
import tomllib
|
||||
|
||||
cargo = tomllib.loads(read('Cargo.toml'))
|
||||
sqlx = cargo['dependencies']['sqlx']
|
||||
assert sqlx['default-features'] is False
|
||||
assert 'sqlite' not in sqlx['features']
|
||||
assert {'mysql', 'postgres'} <= set(sqlx['features'])
|
||||
|
||||
db = read('src/session_store/database.rs')
|
||||
assert 'SqlitePool' not in db
|
||||
assert 'rusqlite::{Connection, params}' in db
|
||||
|
||||
|
||||
def test_user_provisioning_uses_only_sqlite_user_migrator():
|
||||
user_data = read('src/auth/user_data.rs')
|
||||
assert 'db::pool::open_user_db(data_dir, user_hash)' in user_data
|
||||
assert 'embed_migrations' not in user_data
|
||||
assert 'refinery' not in user_data
|
||||
|
||||
|
||||
def test_refinery_dependency_and_history_bridge_are_removed():
|
||||
cargo = read('Cargo.toml')
|
||||
pool = read('src/db/pool.rs')
|
||||
assert 'refinery =' not in cargo
|
||||
assert 'refinery_schema_history' not in pool
|
||||
assert 'sync user_version from refinery' not in pool
|
||||
|
||||
|
||||
def test_release_script_runs_full_locked_rust_gate():
|
||||
script = read('scripts/build-release.sh')
|
||||
assert 'cargo fmt --all -- --check' in script
|
||||
assert 'cargo test --locked' in script
|
||||
assert 'cargo clippy --locked --all-targets -- -D warnings' in script
|
||||
assert 'cargo build --release --locked' in script
|
||||
|
||||
|
||||
def test_session_store_selects_only_backend_specific_session_migration():
|
||||
db = read('src/session_store/database.rs')
|
||||
for dialect in ('sqlite', 'mysql', 'pgsql'):
|
||||
assert f'../../migrations/{dialect}/V047__sessions.sql' in db
|
||||
assert 'embed_migrations' not in db
|
||||
|
||||
|
||||
def test_refinery_is_absent_from_lockfile_too():
|
||||
lock = read('Cargo.lock')
|
||||
assert 'name = "refinery"' not in lock
|
||||
assert 'name = "refinery-core"' not in lock
|
||||
assert 'name = "refinery-macros"' not in lock
|
||||
@@ -1,61 +0,0 @@
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_sqlite_session_migration_executes_and_survives_reopen(tmp_path):
|
||||
db_path = tmp_path / "rustbox.sqlite"
|
||||
migration = (ROOT / "migrations/sqlite/V047__sessions.sql").read_text()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(migration)
|
||||
conn.execute(
|
||||
"""INSERT INTO sessions (
|
||||
token, account_id, browser_id, email, password_ciphertext, user_hash,
|
||||
imap_host, imap_port, imap_tls, imap_tls_verify,
|
||||
smtp_host, smtp_port, smtp_tls, smtp_tls_verify,
|
||||
timeout_secs, created_at_unix, last_accessed_unix, user_agent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
"token-1", "account-1", "browser-1", "user@example.com", "ciphertext", "hash-1",
|
||||
"imap.example.com", 993, 1, 1, "smtp.example.com", 587, 1, 1,
|
||||
3600, 1000, 1000, "pytest",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
reopened = sqlite3.connect(db_path)
|
||||
row = reopened.execute(
|
||||
"SELECT token, account_id, browser_id, password_ciphertext FROM sessions WHERE token = ?",
|
||||
("token-1",),
|
||||
).fetchone()
|
||||
reopened.close()
|
||||
assert row == ("token-1", "account-1", "browser-1", "ciphertext")
|
||||
|
||||
|
||||
def test_sqlite_expiry_query_removes_only_expired_sessions(tmp_path):
|
||||
db_path = tmp_path / "rustbox.sqlite"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript((ROOT / "migrations/sqlite/V047__sessions.sql").read_text())
|
||||
base = (
|
||||
"account", "browser", "user@example.com", "ciphertext", "hash",
|
||||
"imap", 993, 1, 1, "smtp", 587, 1, 1, 100, 1000, "pytest",
|
||||
)
|
||||
for token, last_accessed in [("expired", 1000), ("live", 1950)]:
|
||||
conn.execute(
|
||||
"""INSERT INTO sessions (
|
||||
token, account_id, browser_id, email, password_ciphertext, user_hash,
|
||||
imap_host, imap_port, imap_tls, imap_tls_verify,
|
||||
smtp_host, smtp_port, smtp_tls, smtp_tls_verify,
|
||||
timeout_secs, created_at_unix, last_accessed_unix, user_agent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(token, f"{base[0]}-{token}", *base[1:15], last_accessed, base[15]),
|
||||
)
|
||||
delete_sql = (ROOT / "src/queries/sqlite/queries.rs").read_text()
|
||||
assert "last_accessed_unix + timeout_secs < ?" in delete_sql
|
||||
conn.execute("DELETE FROM sessions WHERE last_accessed_unix + timeout_secs < ?", (2000,))
|
||||
remaining = [row[0] for row in conn.execute("SELECT token FROM sessions ORDER BY token")]
|
||||
conn.close()
|
||||
assert remaining == ["live"]
|
||||
@@ -1,35 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_query_catalog_dead_code_lint_is_scoped_to_queries_only():
|
||||
queries_mod = (ROOT / "src/queries/mod.rs").read_text()
|
||||
for dialect in ("sqlite", "mysql", "pgsql"):
|
||||
assert f"#[allow(dead_code)]\npub mod {dialect};" in queries_mod
|
||||
assert "#![allow(dead_code)]" not in queries_mod
|
||||
|
||||
rust_sources = [p for p in (ROOT / "src").rglob("*.rs") if p != ROOT / "src/queries/mod.rs"]
|
||||
for path in rust_sources:
|
||||
text = path.read_text()
|
||||
assert "#![allow(warnings)]" not in text, path
|
||||
assert "#![allow(dead_code)]" not in text, path
|
||||
|
||||
|
||||
def test_test_only_session_helpers_are_not_in_production_build():
|
||||
text = (ROOT / "src/auth/session.rs").read_text()
|
||||
signatures = [
|
||||
"pub fn new(timeout: Duration) -> Self",
|
||||
"pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option<bool>) -> Self",
|
||||
"pub fn add_account_to_browser(",
|
||||
]
|
||||
for signature in signatures:
|
||||
pos = text.index(signature)
|
||||
prefix = text[max(0, pos - 120):pos]
|
||||
assert "#[cfg(test)]" in prefix, signature
|
||||
|
||||
|
||||
def test_test_only_rfc822_helper_is_not_in_production_build():
|
||||
text = (ROOT / "src/smtp/builder.rs").read_text()
|
||||
pos = text.index("pub fn build_rfc822(")
|
||||
assert "#[cfg(test)]" in text[max(0, pos - 80):pos]
|
||||
@@ -1,32 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
|
||||
def test_dialect_query_catalogs_are_intentionally_warning_silent():
|
||||
mod = read("src/queries/mod.rs")
|
||||
for dialect in ("sqlite", "mysql", "pgsql"):
|
||||
assert f"#[allow(dead_code)]\npub mod {dialect};" in mod
|
||||
|
||||
|
||||
def test_test_only_session_helpers_do_not_enter_production_build():
|
||||
session = read("src/auth/session.rs")
|
||||
signatures = [
|
||||
"pub fn new(timeout: Duration) -> Self",
|
||||
"pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option<bool>) -> Self",
|
||||
"pub fn add_account_to_browser(",
|
||||
]
|
||||
for signature in signatures:
|
||||
pos = session.index(signature)
|
||||
prefix = session[max(0, pos - 120):pos]
|
||||
assert "#[cfg(test)]" in prefix
|
||||
|
||||
|
||||
def test_test_only_rfc822_helper_does_not_enter_production_build():
|
||||
builder = read("src/smtp/builder.rs")
|
||||
pos = builder.index("pub fn build_rfc822")
|
||||
assert "#[cfg(test)]" in builder[max(0, pos - 80):pos]
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html><html lang="en" data-theme="light" data-interface-skin="roundcube-elastic" data-density="compact" data-preview="right" data-scale="normal" data-color-preset="sage" data-font-family="system" data-contrast="normal" data-show-borders="true" data-toolbar-density="normal" data-unread-badge="dot" data-scrollbar-density="thin">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="../src/web/css/00-tokens.css"><link rel="stylesheet" href="../src/web/css/10-base.css"><link rel="stylesheet" href="../src/web/css/20-shell.css"><link rel="stylesheet" href="../src/web/css/30-mail.css"><link rel="stylesheet" href="../src/web/css/40-compose.css"><link rel="stylesheet" href="../src/web/css/50-settings.css"><link rel="stylesheet" href="../src/web/css/55-roundcube-skins.css"><link rel="stylesheet" href="../src/web/css/56-interface-styles.css"><link rel="stylesheet" href="../src/web/css/60-responsive.css"><style>:root{--folder-pane-width:238px;--message-pane-width:430px;--row-height:52px;--sidebar-item-height:35px;--ui-size:14px;--control-height:30px;--appbar-height:44px;--icon-size:18px;--button-pad-x:10px;--button-pad-y:5px;--field-pad-x:8px;--field-pad-y:5px} .fixture-icon{width:18px;height:18px}</style></head>
|
||||
<body><div class="app-view">
|
||||
<header class="appbar"><div class="app-brand"><span class="brand-icon">✉</span><strong>RustBox</strong></div><div class="search-field"><span>⌕</span><input value="Search mail"><span></span><span>☷</span></div><div class="appbar-actions"><button class="icon-button">⚙</button><div class="account-summary"><span>linuxiarz@linuxiarz.pl</span><span>Connected</span></div></div></header>
|
||||
<div class="mail-shell"><button class="folder-drawer-scrim open"></button><aside class="folder-pane open">
|
||||
<div class="folder-drawer-header mobile-only"><button class="icon-button folder-drawer-close" aria-label="Back to messages"><svg viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg></button><strong>Folders</strong></div>
|
||||
<button class="button primary compose-button">✎ <span>Compose</span></button><div class="section-heading"><span>Folders</span><span>+</span></div>
|
||||
<nav class="folder-list"><button class="folder-button active"><span>▣</span><span class="folder-name">INBOX</span><span class="folder-count unread">1424</span></button><button class="folder-button"><span>□</span><span class="folder-name">Archive</span><span></span></button><button class="folder-button"><span>□</span><span class="folder-name">Drafts</span><span></span></button><button class="folder-button"><span>□</span><span class="folder-name">Sent</span><span></span></button><button class="folder-button"><span>□</span><span class="folder-name">Spam</span><span></span></button><button class="folder-button"><span>□</span><span class="folder-name">Trash</span><span></span></button></nav>
|
||||
<div class="section-heading"><span>Labels</span><span>+</span></div><div class="folder-pane-footer"><span>IMAP</span><span>RustBox</span></div></aside>
|
||||
<section class="message-pane"><div class="pane-toolbar"><div class="pane-title"><div><strong>Inbox</strong><span>1,424 messages</span></div></div><div class="toolbar-actions"><button class="icon-button">↻</button></div></div><div class="message-list"><button class="message-row unread"><span class="message-state"></span><span class="message-main"><strong class="message-from">Roundcube Team</strong><span class="message-date">16:42</span></span><span class="message-subject-line"><span class="message-subject">Welcome to your inbox</span></span></button><button class="message-row active"><span class="message-state"></span><span class="message-main"><strong class="message-from">Example sender</strong><span class="message-date">15:17</span></span><span class="message-subject-line"><span class="message-subject">Selected message</span></span></button><button class="message-row"><span class="message-state"></span><span class="message-main"><strong class="message-from">Newsletter</strong><span class="message-date">Yesterday</span></span><span class="message-subject-line"><span class="message-subject">A regular message row</span></span></button></div></section>
|
||||
<section class="reader-pane"><div class="reader-toolbar"><button class="button quiet">Reply</button><button class="button quiet">Forward</button></div><article class="message-content"><header class="message-header"><h1>Selected message</h1><div class="message-meta"><div class="avatar">E</div><div class="sender"><strong>Example sender</strong><span>to linuxiarz</span></div></div></header><div class="message-body"><p>This fixture uses the real RustBox CSS bundle and component classes.</p><p><a href="#">Roundcube accent link</a></p><button class="button primary">Primary action</button></div></article></section>
|
||||
</div></div><script>const q=new URLSearchParams(location.search);document.documentElement.dataset.interfaceSkin=q.get('skin')||'roundcube-elastic';document.documentElement.dataset.theme=q.get('theme')||'light';</script></body></html>
|
||||
Reference in new issue
Block a user