Files
RustBox/scripts/validate-release-static.py
T
Mateusz Gruszczyński de4d70970c
RustBox CI / rustbox (push) Canceled after 0s
0.2.24
2026-11-04 23:34:09 +01:00

1103 lines
100 KiB
Python
Executable File

#!/usr/bin/env python3
"""Static/offline release checks for RustBox.
These checks intentionally avoid pretending to replace `cargo test`: they validate
configuration, SQL migrations, Docker examples, query-catalog parity and release
metadata in environments where the Rust toolchain or external services are absent.
Release version and migration versions are discovered from the repository instead of
being duplicated here, so adding V052/V053 does not require editing this validator.
"""
from __future__ import annotations
import base64
import pathlib
import re
import sqlite3
import subprocess
import sys
import tomllib
try:
import yaml
except ImportError: # pragma: no cover - validation host dependency
yaml = None
ROOT = pathlib.Path(__file__).resolve().parents[1]
errors: list[str] = []
checks: list[str] = []
def ok(message: str) -> None:
checks.append(message)
def require(condition: bool, message: str) -> None:
if condition:
ok(message)
else:
errors.append(message)
def read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def extract_section(text: str, begin: str, end: str) -> str:
require(begin in text and end in text, f"schema markers present: {begin} / {end}")
if begin not in text or end not in text:
return ""
return text.split(begin, 1)[1].split(end, 1)[0]
MIGRATION_DIALECTS = (("SQLITE", "sqlite"), ("MYSQL", "mysql"), ("PGSQL", "pgsql"))
MIGRATION_FILE_RE = re.compile(r"^V(?P<version>\d+)__(?P<name>[A-Za-z0-9_]+)\.sql$")
def migration_label(version: int) -> str:
return f"V{version:03d}"
def parse_base_schema_version(pool_text: str) -> int:
match = re.search(
r"pub\s+const\s+BASE_SCHEMA_VERSION\s*:\s*u32\s*=\s*(\d+)\s*;",
pool_text,
)
require(match is not None, "runtime declares BASE_SCHEMA_VERSION")
return int(match.group(1)) if match else 0
def discover_migrations(dialect: str) -> list[tuple[int, str]]:
migration_dir = ROOT / "migrations" / dialect
found: list[tuple[int, str]] = []
for path in sorted(migration_dir.glob("V*.sql")):
match = MIGRATION_FILE_RE.fullmatch(path.name)
require(match is not None, f"migration filename is valid: {dialect}/{path.name}")
if match is None:
continue
found.append((int(match.group("version")), path.name))
versions = [version for version, _ in found]
require(len(versions) == len(set(versions)), f"migration versions are unique for {dialect}")
return sorted(found, key=lambda item: item[0])
def migration_containing(dialect: str, *needles: str) -> tuple[int, str] | None:
matches: list[tuple[int, str]] = []
for version, filename in MIGRATIONS[dialect]:
text = read(f"migrations/{dialect}/{filename}")
if all(needle in text for needle in needles):
matches.append((version, filename))
return matches[-1] if matches else None
# Repository-derived release and migration metadata.
pool = read("src/db/pool.rs")
BASE_SCHEMA_VERSION = parse_base_schema_version(pool)
MIGRATIONS = {dialect: discover_migrations(dialect) for _, dialect in MIGRATION_DIALECTS}
ALL_MIGRATION_VERSIONS = sorted({version for items in MIGRATIONS.values() for version, _ in items})
LATEST_SCHEMA_VERSION = max([BASE_SCHEMA_VERSION, *ALL_MIGRATION_VERSIONS])
NEXT_SCHEMA_VERSION = LATEST_SCHEMA_VERSION + 1
# Release/toolchain metadata.
cargo = tomllib.loads(read("Cargo.toml"))
PROJECT_VERSION = cargo["package"]["version"]
RUST_VERSION = cargo["package"]["rust-version"]
require(bool(re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", PROJECT_VERSION)), f"Cargo package version is valid: {PROJECT_VERSION}")
require(bool(RUST_VERSION), f"Cargo rust-version is declared: {RUST_VERSION}")
toolchain = tomllib.loads(read("rust-toolchain.toml"))
require(toolchain["toolchain"]["channel"] == RUST_VERSION, f"rust-toolchain pins Cargo rust-version {RUST_VERSION}")
require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds")
lock = tomllib.loads(read("Cargo.lock"))
lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {})
require(lock_rustbox.get("version") == PROJECT_VERSION, f"Cargo.lock RustBox package version matches {PROJECT_VERSION}")
require(cargo["dependencies"].get("encoding_rs") == "0.8", "direct charset decoder dependency is pinned")
require("encoding_rs" in lock_rustbox.get("dependencies", []), "Cargo.lock records direct charset decoder dependency")
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing")
release_script = read("scripts/build-release.sh")
release_script_flat = " ".join(release_script.replace("\\", " ").split())
require("cargo test --locked" in release_script_flat, "release script requires the full Rust test suite")
require("cargo clippy --locked --all-targets -- -D warnings" in release_script_flat, "release script requires warning-free Clippy")
require("cargo build --release --locked" in release_script_flat, "release script requires a locked release build")
package_script = read("scripts/package-release.sh")
require("./scripts/build-release.sh" in package_script, "release packaging runs the full build gate before creating artifacts")
require("make_zip.py" in package_script and "sha256sum" in package_script, "release packaging creates ZIP and SHA256 only after the build gate")
require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks")
require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate")
require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate")
require(read("README.md").startswith(f"# RustBox {PROJECT_VERSION}"), f"README identifies RustBox {PROJECT_VERSION}")
require(read("RELEASE-MANIFEST.txt").startswith(f"RustBox {PROJECT_VERSION}"), f"release manifest identifies RustBox {PROJECT_VERSION}")
require(read("RELEASE-VALIDATION.txt").startswith(f"RustBox {PROJECT_VERSION}"), f"release validation identifies RustBox {PROJECT_VERSION}")
require(read("docs/IMPLEMENTATION_STATUS.md").startswith(f"# RustBox {PROJECT_VERSION}"), f"implementation status identifies RustBox {PROJECT_VERSION}")
bootstrap_js = read("src/web/js/00-bootstrap.js")
bindings_js = read("src/web/js/95-bindings-boot.js")
realtime_js = read("src/web/js/80-settings-realtime.js")
mail_actions_js = read("src/web/js/60-mail-actions-search.js")
auth_js = read("src/web/js/20-preferences-auth.js")
reader_js = read("src/web/js/40-messages-reader.js")
compose_js = read("src/web/js/50-compose.js")
compose_css = read("src/web/css/40-compose.css")
mail_css = read("src/web/css/30-mail.css")
require("perPage: 300" in bootstrap_js and "messages_per_page: 300" in bootstrap_js and 'message_loading_mode: "pagination"' in bootstrap_js, "browser mailbox defaults to 300-message pagination")
require(re.search(r"default_per_page\(\)\s*->\s*u32\s*\{\s*300\s*\}", read("src/routes/messages/types.rs")) is not None, "folder message API default page size is 300")
require('1..=300' in read("src/routes/messages/mod.rs"), "folder message API accepts up to 300 messages per page")
require('20..=300' in read("src/db/mail_preferences.rs") and '"pagination" | "progressive" | "all"' in read("src/db/mail_preferences.rs"), "mailbox preferences accept 300-message pages and the three loading modes")
require('const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;' in bindings_js, "single-key shortcuts preserve Ctrl/Cmd/Alt browser shortcuts")
require('realtimeNotificationsReady' in realtime_js and 'suppressRealtimeNotificationsForFolder' in realtime_js, "realtime new-mail notification gating is present")
require('id="account-switcher"' in read("src/web/index.html") and 'renderAccountSwitcher' in auth_js and 'state.accounts.length > 1' in auth_js, "multi-account quick switcher appears only for multiple signed-in accounts")
require('id="account-notification-list"' in read("src/web/index.html") and 'notificationAccountEnabled' in auth_js and 'notificationPrefsByAccount' in bootstrap_js and 'loadCombinedNotificationPreferences' in realtime_js, "browser session can select multiple accounts for combined notifications")
require('realtimeFolderIsDrafts' in realtime_js and 'if (realtimeFolderIsDrafts(data?.folder)) return true;' in realtime_js, "realtime new-mail notifications are suppressed for Drafts folders")
require('const draftsFolder = syncImap' in compose_js and 'suppressRealtimeNotificationsForFolder(draftsFolder)' in compose_js and 'releaseRealtimeSuppression();' in compose_js, "manual IMAP draft save suppresses its own realtime new-mail notification")
require('suppressRealtimeNotificationsForFolder(notificationDestination)' in mail_actions_js, "bulk destination notifications are suppressed")
require('realtimeNotificationSuppressedMessages: new Map()' in bootstrap_js and 'suppressRealtimeNotificationForMessage' in realtime_js and 'consumeRealtimeMessageNotificationSuppression' in realtime_js and 'suppressIntentionalMoveNotification' in mail_actions_js, "intentional message moves suppress the matching realtime new-mail event by destination UID")
require('finishAuthenticatedStartup({ showLoginToast = false } = {})' in auth_js and 'finishAuthenticatedStartup({ showLoginToast: true })' in auth_js and bindings_js.count('finishAuthenticatedStartup();') >= 1, "login success toast is limited to real login, not refresh/session restoration")
require('<meta charset="utf-8">${csp}' in reader_js, "message preview srcdoc explicitly declares UTF-8")
require('function resizeReaderMessageFrame()' in reader_js and 'new ResizeObserver(scheduleReaderMessageFrameResize)' in reader_js and '--reader-message-frame-height' in mail_css and 'min-height: 68vh' not in mail_css, "HTML message preview uses the full remaining reader height instead of fixed 68vh")
require('function sanitizeMessagePreviewHtml(html)' in bootstrap_js and 'if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");' in bootstrap_js and '!["_self", "_parent", "_top"].includes(target)' in bootstrap_js and 'rel.add("noopener");' in bootstrap_js and 'rel.add("noreferrer");' in bootstrap_js and 'sanitizeMessagePreviewHtml(detail.html)' in reader_js, "message preview links default to safe new-window navigation without overriding explicit targets")
require('sourceDoc.head.querySelectorAll("style")' in bootstrap_js and 'messagePreviewBodyPresentation' in reader_js and 'iframe.style.background = previewTheme.surface;' in reader_js and 'background: var(--reader-bg);' in mail_css, "adaptive HTML-mail preview preserves head CSS/body presentation and never exposes a hard-coded white iframe canvas")
require('attachmentContentUrl(folder, uid, attachment)' in reader_js and 'frame.src = attachmentContentUrl(folder, uid, attachment);' in reader_js and 'URL.createObjectURL(blob)' in reader_js, "PDF preview uses authenticated inline URL while other attachment previews retain blob handling")
require('function composeAttachmentInsertionRange' in compose_js and 'target.collapse(false);' in compose_js and 'range.insertNode(inserted);' in compose_js and 'document.execCommand("insertHTML"' not in compose_js, "compose attachment embedding uses non-destructive DOM Range insertion")
require('position: fixed;' in compose_css and '.compose-image-actions' in compose_css and 'grid-template-rows: auto auto auto minmax(240px, 1fr) auto auto auto auto;' in compose_css, "inline image actions float without consuming a composer grid row")
require('function beginInlineImageResize' in compose_js and 'data-image-resize="nw"' in read("src/web/index.html") and '.compose-image-resize-handle' in compose_css, "selected inline images expose pointer resize handles")
require('application/x-rustbox-inline-image' in bindings_js and 'function moveInlineImageToRange' in compose_js, "inline images can be repositioned by drag and drop")
require('clipboardData' in bindings_js and 'uploadInlineImage(images, range)' in bindings_js, "clipboard images are uploaded and embedded inline")
require('.compose-attachment-kind' in compose_css and 'Image attachment' in compose_js and 'Inline image' in compose_js, "composer distinguishes embedded images from regular attachments")
require("function mergeComposeAttachments" in compose_js and "await refreshComposeAttachmentsFromServer(id);" in compose_js and "attachments: mergeComposeAttachments(local.attachments, server.attachments)" in compose_js, "composer preserves multiple attachments and draft recovery merges authoritative server attachment metadata")
require("const attachments = mergeComposeAttachments(state.composeAttachments);" in compose_js and "Inline link" in compose_js and "detachComposeAttachmentFromBody" in compose_js, "composer attachment strip keeps inline and regular attachments visible together")
require('if (state.composeUploading) {' in compose_js and 'scheduleUnusedInlineAttachmentCleanup();' in compose_js and 'staleSignatureIds' in compose_js, "signature CID cleanup waits for in-flight uploads before deleting apparently unreferenced signature images")
require('function preferredComposeIdentity()' in read("src/web/js/72-openpgp-identities.js") and 'activeAccountEmail().toLowerCase()' in read("src/web/js/72-openpgp-identities.js") and 'if (preferred) select.value = String(preferred.id);' in read("src/web/js/72-openpgp-identities.js") and 'return state.identities.find((identity) => identity.id === id) || preferredComposeIdentity();' in read("src/web/js/72-openpgp-identities.js"), "composer resolves a concrete active/default identity before inserting signatures")
require('const hasAccountIdentity = Boolean(normalizedAccountEmail)' in read("src/web/js/72-openpgp-identities.js") and 'if (!hasAccountIdentity) {' in read("src/web/js/72-openpgp-identities.js"), "composer does not duplicate the active account address when a matching identity exists")
require('const command = key === "z" ? (event.shiftKey ? "redo" : "undo")' in bindings_js and 'document.execCommand(command, false, null);' in bindings_js, "composer supports Ctrl/Cmd+Z undo and redo shortcuts")
require('.message-pagination {' in mail_css and 'grid-template-columns: 30px 30px auto 30px 30px;' in mail_css and '.pagination-arrow {' in mail_css and '.load-more {' in mail_css and 'width: 100%;' in mail_css.split('.load-more {', 1)[1].split('}', 1)[0], "message pagination uses compact first/previous/next/last controls while progressive-load controls span the message list width")
require('id="compose-fields-toggle"' in read("src/web/index.html") and 'function setComposeFieldsCollapsed' in compose_js and 'function toggleComposeFields' in compose_js and '.compose-dialog.compose-fields-collapsed .compose-addresses' in compose_css and 'setComposeFieldsCollapsed(false)' in bindings_js, "composer addressing fields can collapse to maximize editor space and reopen on invalid recipient input")
require('id="compose-save-draft"' in read("src/web/index.html") and '$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);' in bindings_js and 'saveCurrentDraft(false, true, true)' in compose_js, "composer exposes an explicit manual IMAP draft save before cancel")
require('rememberSignatureRange' in bindings_js and 'selectedSignatureRange' in bindings_js and 'if (!range || range.collapsed) return null;' in bindings_js and 'state.identitySignatureRange = null;' in bindings_js and '.signature-editor-toolbar button' in bindings_js and 'event.preventDefault();' in bindings_js and 'restoreEditorRange(range, signatureEditor);' in bindings_js, "signature formatting preserves real selections and refuses collapsed-caret commands that would enable sticky typing styles")
require('id="identity-signature-html-dialog"' in read("src/web/index.html") and 'id="identity-signature-html-source"' in read("src/web/index.html") and 'id="identity-signature-html-preview-editor"' in read("src/web/index.html") and 'openIdentitySignatureHtmlEditor' in read("src/web/js/72-openpgp-identities.js") and 'insertIdentitySignatureHtmlImage' in read("src/web/js/72-openpgp-identities.js"), "signature management exposes sanitized raw-HTML import with visual editing and image insertion")
require('.identity-signature-grid textarea,' in read("src/web/css/50-settings.css") and 'height: 240px;' in read("src/web/css/50-settings.css") and '.identity-signature-grid .signature-editor-shell {' in read("src/web/css/50-settings.css"), "plain-text and HTML signature controls use the same fixed editor height")
require('messageLoadPending: 0' in bootstrap_js and 'function beginMessageLoadActivity()' in realtime_js and 'function endMessageLoadActivity()' in realtime_js and reader_js.count('beginMessageLoadActivity();') >= 3 and reader_js.count('endMessageLoadActivity();') >= 3, "mail loading spinner covers list pagination/progressive/all loading and individual message retrieval")
require('function selectMessageRange(message)' in read("src/web/js/30-folders-tags.js") and 'if (event.shiftKey)' in reader_js and 'setMessageSelectionAnchor(message);' in reader_js, "Shift-click selects a contiguous visible-message range from the last selection anchor")
require('id="page-first"' in read("src/web/index.html") and 'id="page-last"' in read("src/web/index.html") and 'function goToMailboxPage(targetPage)' in reader_js and 'goToMailboxPage(mailboxPageCount() - 1)' in bindings_js, "mailbox pagination can jump directly to the first and last page")
index_html = read("src/web/index.html")
require('<div class="identity-signature-field"><span id="identity-signature-label">HTML signature / footer</span>' in index_html and 'aria-labelledby="identity-signature-label"' in index_html and '<label class="identity-signature-field">' not in index_html, "HTML signature editor is not wrapped in a label that can implicitly activate the Bold toolbar button")
require('function ensureComposeEntryBeforeSignature' in read("src/web/js/72-openpgp-identities.js") and 'data-rustbox-signature-entry' in read("src/web/js/72-openpgp-identities.js") and 'compose-body-entry' in compose_css, "new visible signatures keep an editable compose body entry above the signature")
require('.compose-signature.compose-signature-new' in compose_css and 'margin-top: 2.4em;' in compose_css and 'border-top: 1px solid color-mix(in srgb, var(--line) 58%, transparent);' in compose_css and 'removeAttribute("data-rustbox-signature-entry")' in compose_js, "new-message signature spacing is scoped, the separator is subdued, and editor-only scaffolding is stripped from outgoing HTML")
require('data-panel="appearance"' in read("src/web/index.html") and 'id="pref-compact-wide-rows"' in read("src/web/index.html") and 'id="mail-pref-compact-wide-rows"' not in read("src/web/index.html") and 'saveCompactWideRowsAppearance' in auth_js and 'compact_wide_rows: true' in bootstrap_js and 'data-compact-wide-rows="true"' not in read("src/web/index.html") and '@container message-pane (min-width: 520px)' in mail_css, "wide message-row compaction is an Appearance preference, persisted, default-on, and width-gated")
display_prefs_rs = read("src/db/display_preferences.rs")
index_html = read("src/web/index.html")
require('bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760)' in bindings_js and 'value.clamp(300, 760).to_db_value()' in display_prefs_rs and 'id="pref-message-width" type="range"\n min="300" max="760"' in index_html, "message pane resize limits are aligned at 300..760 px and normalized server-side")
require('bindPaneResizer("#folder-resizer", "folderPaneWidth", 180, 420)' in bindings_js and 'value.clamp(180, 420).to_db_value()' in display_prefs_rs and 'id="pref-folder-width" type="range"\n min="180" max="420"' in index_html, "folder pane resize limits are aligned at 180..420 px and normalized server-side")
require('suppressPaneWidthValidationError' in auth_js and 'temporarilyDisablePanePreference' in auth_js and 'data.temporarilyDisabled' not in auth_js and 'handle.dataset.temporarilyDisabled = "true"' in auth_js and 'if (!suppressPaneWidthValidationError(error)) toast(error.message, "error")' in auth_js, "pane-width validation incompatibilities disable the in-view resizer without a user-facing error")
require('id="identity-signature-mode"' in read("src/web/index.html") and 'value="silent"' in read("src/web/index.html") and 'value="visible"' in read("src/web/index.html") and 'signature_insert_mode' in read("src/web/js/72-openpgp-identities.js"), "identity UI exposes silent and visible signature insertion modes")
require('id="pref-adaptive-email-colors"' in read("src/web/index.html") and 'adaptive_email_colors' in display_prefs_rs and 'adaptiveEmailColors' in auth_js and 'adaptMessagePreviewColors' in reader_js, "Appearance exposes persisted adaptive HTML-mail colors and the reader applies them")
require('id="mail-pref-loading-mode"' in index_html and 'id="message-pagination"' in index_html and 'function changeMailboxPage(delta)' in reader_js and 'function loadNextProgressivePage()' in reader_js and 'function loadAllMessages()' in reader_js and 'handleMessageListScroll' in bindings_js, "Mailbox settings expose pagination, progressive loading and load-all behavior")
require('identity.signature_insert_mode != "visible"' in read("src/routes/send.rs") and 'data-rustbox-signature' in read("src/web/js/72-openpgp-identities.js"), "silent signatures append server-side while visible signatures remain composer-owned and de-duplicatable")
require('id="compose-delete-draft"' in read("src/web/index.html") and '$("#compose-delete-draft").addEventListener("click", deleteComposeDraft);' in bindings_js and 'function deleteComposeDraft()' in compose_js and 'imap_uid_validity' in compose_js, "composer exposes permanent draft deletion with current IMAP coordinates")
require('if req.sync_imap {' in read("src/routes/drafts.rs") and 'Failed to save draft to IMAP' in read("src/routes/drafts.rs") and 'AppError::ServiceUnavailable' in read("src/routes/drafts.rs"), "manual IMAP draft save fails visibly when the IMAP snapshot cannot be appended")
drafts_rs = read("src/routes/drafts.rs")
require('.keep_bcc()' in drafts_rs and 'if valid_recipients == 0 {' in drafts_rs and 'builder = builder.envelope(envelope);' in drafts_rs, "IMAP draft snapshots allow recipient-less drafts and preserve Bcc headers")
require('db::special_folders::get_role(conn, "drafts")?' in drafts_rs and 'default_drafts_folder' in drafts_rs and 'find_folder_by_attribute(conn, "\\\\Drafts")?' in drafts_rs, "manual IMAP draft save honors configured and detected Drafts folders")
require('for common_name in ["Drafts", "Draft"]' in drafts_rs, "manual IMAP draft save recognizes common Draft and Drafts folder names")
require('get_imap_draft_handler' in drafts_rs and '/messages/{folder}/{uid}/draft-compose' in read("src/routes/mod.rs") and 'fetch_body(&creds, &folder, uid)' in drafts_rs, "saved IMAP drafts reopen from RFC822/MIME without a local draft-row dependency")
require('delete_imap_draft_source' in drafts_rs and 'Incomplete IMAP draft deletion coordinates' in drafts_rs and 'let mut attachments_deleted = 0usize;' in drafts_rs, "draft deletion verifies the IMAP source and cascades local attachment cleanup")
require('messageLooksLikeImapDraft' in reader_js and 'await openImapDraftComposer(folder, uid);' in reader_js and 'state.composeRemoteDraft = draft.imap_source' in compose_js, "IMAP Drafts messages open directly in the normal composer")
require('imap_source' in compose_js and 'imap_attachments' in compose_js and 'load_imap_source_attachments' in drafts_rs and 'remove_imap_source_snapshot' in drafts_rs, "IMAP draft edits preserve remote MIME attachments and replace the source snapshot safely")
require('Attachment::new_inline_with_name' in drafts_rs and 'Attachment::new_inline_with_name' in read("src/smtp/builder.rs"), "inline MIME parts preserve attachment filenames in saved drafts and sent messages")
require('mail_parser::MessageParser::default().parse(synthetic.as_bytes())' in read("src/imap/parse.rs") and 'decode_header_bytes' in read("src/imap/parse.rs"), "RFC 2047 header decoding uses mail-parser and tolerant envelope byte handling")
require('encoding_rs::Encoding::for_label' in read("src/imap/parse.rs") and 'decodes_polish_rfc2047_subjects' in read("src/imap/parse.rs"), "Polish UTF-8/ISO-8859-2/Windows-1250 header decoding is covered")
require('let is_pdf = response_content_type_is(&response, "application/pdf");' in read("src/routes/mod.rs") and 'HeaderValue::from_static("SAMEORIGIN")' in read("src/routes/mod.rs") and "HeaderValue::from_static(\"frame-ancestors 'self'\")" in read("src/routes/mod.rs"), "security headers allow same-origin framing for inline PDF previews only")
index_html = read("src/web/index.html")
require('class="compose-limit-summary"' in index_html and 'Per-file upload limit set with' not in index_html and 'Hard limit for the complete RFC822/MIME message' not in index_html, "Composing shows compact raw size limits without implementation details")
for rel, label in (("RELEASE-MANIFEST.txt", "release manifest"), ("migrations/README.md", "migration guide")):
migration_doc = read(rel)
require(
migration_label(BASE_SCHEMA_VERSION) in migration_doc
and migration_label(LATEST_SCHEMA_VERSION) in migration_doc
and migration_label(NEXT_SCHEMA_VERSION) in migration_doc,
f"{label} documents baseline {migration_label(BASE_SCHEMA_VERSION)}, current {migration_label(LATEST_SCHEMA_VERSION)} and next {migration_label(NEXT_SCHEMA_VERSION)}",
)
roundcube_script = read("scripts/migrate_roundcube.py")
roundcube_fallback = re.search(r"versions\s*=\s*\[\s*(\d+)\s*\]", roundcube_script)
require(
roundcube_fallback is not None and int(roundcube_fallback.group(1)) == BASE_SCHEMA_VERSION,
f"Roundcube provisioning fallback follows the {migration_label(BASE_SCHEMA_VERSION)} consolidated baseline",
)
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value")
require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host")
dev_sh = read("dev.sh")
require('ENV_FILE="${RUSTBOX_ENV_FILE:-$ROOT/.env}"' in dev_sh and '. "$ENV_FILE"' in dev_sh, "development launcher loads project .env")
require(dev_sh.find('. "$ENV_FILE"') < dev_sh.find('cargo run --quiet --bin rustbox -- --check-config'), "development launcher loads .env before config validation")
require('warn,rustbox=debug,tower_http=info' in dev_sh, "development launcher keeps dependencies at WARN while RustBox stays DEBUG")
require('case "${RUST_LOG:-}" in' in dev_sh and 'trace)' in dev_sh and 'info)' in dev_sh, "development launcher scopes legacy/simple global RUST_LOG levels")
require('RUST_LOG=warn,rustbox=debug,tower_http=info' in read(".env.example"), "development environment example does not enable noisy dependency INFO logs")
main_rs = read("src/main.rs")
require('"debug" => "warn,rustbox=debug,tower_http=info"' in main_rs and 'EnvFilter::new(default_log_filter(log_level))' in main_rs, "config log_level controls RustBox verbosity without globally enabling dependency DEBUG/INFO")
require("pub struct SessionPolicyResponse" in read("src/routes/auth.rs") and "pgp_enabled: bool" in read("src/routes/auth.rs"), "authenticated session policy exposes OpenPGP availability")
require("setOpenPgpAvailability" in read("src/web/js/72-openpgp-identities.js"), "UI hides administratively disabled OpenPGP")
storage_rs = read("src/storage/mod.rs")
require(
".with_allow_http(storage.allow_http)" in storage_rs.split("let client_options = ClientOptions::new()", 1)[1].split("let retry =", 1)[0],
"S3 allow_http is applied to the final ClientOptions",
)
require(
".with_allow_http(storage.allow_http)" not in storage_rs.split("let mut builder = AmazonS3Builder::from_env()", 1)[1].split("if let Some(endpoint)", 1)[0],
"S3 builder does not overwrite allow_http with replacement client options",
)
require(
"s3_http_endpoint_works_when_explicitly_allowed" in storage_rs,
"S3 HTTP regression test is present",
)
config_rs = read("src/config.rs")
routes_mod = read("src/routes/mod.rs")
require("pub sieve_enabled: bool" in config_rs and "pub diagnostics_enabled: bool" in config_rs and "pub about_enabled: bool" in config_rs, "configuration exposes Sieve, diagnostics and About feature gates")
require("pub multi_account_enabled: bool" in config_rs and "multi_account_enabled: true" in config_rs and "MULTI_ACCOUNT_DISABLED" in read("src/routes/auth.rs"), "administrator can disable multi-account browser sessions and second-account login is rejected")
require("!config.multi_account_enabled" in read("src/realtime/ws.rs") and "account_id" in read("src/realtime/ws.rs") and "?account_id=" in realtime_js, "disabled multi-account mode limits realtime monitoring to the active account")
require("pub security_headers_enabled: bool" in config_rs and "security_headers_enabled: false" in config_rs, "security headers are configurable and disabled by default")
require("pub struct SyncConfig" in config_rs and "pub interval_secs: u64" in config_rs and "pub deep_index_batch_size: u32" in config_rs, "IMAP sync cadence and deep-index batch size are configurable")
require("pub deep_index: bool" in config_rs and "deep_index: false" in config_rs, "server-wide automatic deep index is configurable and production-safe by default")
system_diag_rs = read("src/routes/system_diagnostics.rs")
session_rs = read("src/auth/session.rs")
session_persistence_rs = read("src/session_store/mod.rs")
require("pub struct DiagnosticsEndpointConfig" in config_rs and "pub diagnostics_endpoint: DiagnosticsEndpointConfig" in config_rs, "administrator diagnostics endpoint has a dedicated configuration block")
require('default_diagnostics_allowed_networks' in config_rs and '"127.0.0.1/32"' in config_rs and '"::1/128"' in config_rs, "administrator diagnostics allowlist defaults to IPv4/IPv6 loopback")
require('diagnostics_endpoint.path must be an absolute /api/system/' in config_rs and 'diagnostics_endpoint.token must contain at least 24 characters' in config_rs and 'diagnostics_endpoint.basic_password must contain at least 16 characters' in config_rs, "administrator diagnostics validates namespace and authentication strength")
require('if config.diagnostics_endpoint.enabled' in routes_mod and 'get(system_diagnostics::system_diagnostics)' in routes_mod, "administrator diagnostics route is registered only when enabled")
require('diagnostics_client_ip' in system_diag_rs and 'proxy headers received from an untrusted peer' in system_diag_rs, "administrator diagnostics fails closed on untrusted reverse-proxy headers")
require('parsed_diagnostics_allowed_networks' in system_diag_rs and 'return forbidden();' in system_diag_rs, "administrator diagnostics enforces source IP/CIDR allowlist before probes")
require('constant_time_secret_eq' in system_diag_rs and 'basic_credentials' in system_diag_rs and 'bearer_token' in system_diag_rs, "administrator diagnostics supports protected token and Basic authentication")
require('HeaderValue::from_static("no-store")' in system_diag_rs and 'harden_diagnostics_response' in system_diag_rs, "administrator diagnostics responses are explicitly non-cacheable")
require('"database": db_diag' in system_diag_rs and '"storage": {' in system_diag_rs and '"search": search_status' in system_diag_rs and '"sessions": {' in system_diag_rs, "administrator diagnostics reports database, storage, search and session state")
require('bruteForceSignal' in system_diag_rs and 'diagnosticsAttackSignal' in system_diag_rs and 'MAX_SECURITY_EVENTS' in system_diag_rs, "administrator diagnostics includes bounded brute-force/access-denial telemetry")
require('pub struct SessionPersistenceDiagnostics' in session_persistence_rs and 'diagnostics_snapshot' in session_rs, "administrator diagnostics reports local and persisted session state")
require('user_hash: Option<String>' in session_rs and 'include_user_identifiers.then' in session_rs, "administrator diagnostics omits stable user identifiers unless explicitly enabled")
require('schema_aligned: Option<bool>' in read("src/db/pool.rs") and 'expected_schema_version' in read("src/db/pool.rs"), "administrator diagnostics reports database schema alignment")
require('connected_clients' in read("src/session_store/redis_store.rs") and 'used_memory_bytes' in read("src/session_store/redis_store.rs") and 'master_link_status' in read("src/session_store/redis_store.rs"), "administrator diagnostics reports Redis runtime INFO without credentials")
require('"idleConnections"' in system_diag_rs and 'diagnostics_snapshot' in read("src/realtime/idle.rs"), "administrator diagnostics reports active IMAP IDLE connections")
require('cluster_status.eq_ignore_ascii_case("green")' in read("src/search/engine.rs") and 'cluster_status.eq_ignore_ascii_case("red")' in read("src/search/engine.rs"), "administrator diagnostics treats OpenSearch health conservatively")
require('middleware::from_fn(system_diagnostics::observe_requests)' in routes_mod and routes_mod.count('if config.diagnostics_endpoint.enabled') >= 2, "administrator diagnostics telemetry middleware is installed only when the endpoint is enabled")
require('sanitize_url' in system_diag_rs and 'secretKeyConfigured' in system_diag_rs and 'passwordConfigured' in system_diag_rs and 'credentialConfigured' in system_diag_rs, "administrator diagnostics redacts configured credentials and DSN userinfo")
require('/proc/' not in system_diag_rs and 'systemMemoryTotalKiB' not in system_diag_rs and 'loadAverage' not in system_diag_rs and 'availableProcessors' not in system_diag_rs, "administrator diagnostics avoids host-specific /proc and machine-wide resource metrics")
require((ROOT / "docs/SYSTEM_DIAGNOSTICS.md").is_file() and "disabled by default" in read("docs/SYSTEM_DIAGNOSTICS.md") and "HTTP `403`" in read("docs/SYSTEM_DIAGNOSTICS.md"), "administrator diagnostics security and operations are documented")
require("if config.sieve_enabled" in routes_mod and "if config.diagnostics_enabled" in routes_mod, "disabled Sieve and diagnostics routes are not registered")
require(".fallback(|| async { axum::http::StatusCode::NOT_FOUND })" in routes_mod, "API namespace has an explicit 404 fallback before the SPA fallback")
require("if config.security_headers_enabled" in routes_mod and "add_security_headers" in routes_mod, "security-header middleware is opt-in")
ui_rs = read("src/ui.rs")
pgp_rs = read("src/routes/pgp.rs")
contacts_rs = read("src/routes/contacts.rs")
require("X_CONTENT_TYPE_OPTIONS" not in ui_rs and "x-content-type-options" not in pgp_rs and "x-content-type-options" not in contacts_rs, "security headers are centralized behind the opt-in middleware")
require("response_content_type_is" in routes_mod, "security-header middleware classifies response content types")
require("headers.insert(\n headers.insert(" not in routes_mod, "security-header middleware has no duplicated insert fragment")
require("if is_html || is_css" in routes_mod, "Referrer-Policy is limited to HTML/CSS responses")
require("if is_html {" in routes_mod, "document-only browser policies are limited to HTML responses")
require("script-src 'self'; script-src-attr 'none'" in routes_mod, "HTML CSP permits only same-origin external scripts and disables script attributes")
require("style-src 'self'; style-src-attr 'unsafe-inline'" in routes_mod and "style-src 'self' 'unsafe-inline'" not in routes_mod, "HTML CSP limits unsafe-inline to dynamic style attributes rather than stylesheet blocks")
require("sha256-" not in routes_mod and "script-src 'self' 'unsafe-inline'" not in routes_mod, "HTML CSP does not rely on inline-script hashes or unsafe-inline")
require('headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))' in routes_mod, "API security middleware forces private responses to no-store")
require(
"HeaderValue::from_static(\"frame-ancestors 'none'\")" in routes_mod
and 'headers.insert("x-frame-options", HeaderValue::from_static("DENY"))' in routes_mod,
"browser API security profile includes explicit frame denial",
)
html_security_start = routes_mod.find("if is_html {")
html_security_end = routes_mod.find("\n response", html_security_start)
require(
html_security_start >= 0
and html_security_end > html_security_start
and routes_mod.count('"permissions-policy"') == 1
and routes_mod.count('"cross-origin-opener-policy"') == 1
and html_security_start < routes_mod.find('"permissions-policy"') < html_security_end
and html_security_start < routes_mod.find('"cross-origin-opener-policy"') < html_security_end,
"API security profile keeps Permissions-Policy and COOP on documents only",
)
require('"cross-origin-resource-policy"' in routes_mod and 'HeaderValue::from_static("same-origin")' in routes_mod, "security middleware applies same-origin CORP where appropriate")
route_tests = read("src/routes/tests.rs")
auth_routes = read("src/routes/auth.rs")
auth_js = read("src/web/js/20-preferences-auth.js")
require("security_headers_are_opt_in_and_content_type_aware" in route_tests, "security-header regression test covers content-type-aware behavior")
require("pdf_api_responses_allow_same_origin_preview_frames" in route_tests, "PDF preview framing has a same-origin security regression test")
require("auth_policy_exposes_only_login_requirements_before_authentication" in route_tests, "public auth-policy minimization has a regression test")
require("pub struct SessionPolicyResponse" in auth_routes and '.route("/session-policy", get(auth::session_policy))' in routes_mod, "feature policy is exposed only behind the authenticated session route")
require('request("/auth/session-policy"' in auth_js and "async function loadSessionPolicy()" in auth_js, "frontend loads optional feature policy only after authentication")
require("script-src 'self' 'unsafe-inline'" in route_tests, "security regression test explicitly rejects unsafe-inline script policy")
security_test_start = route_tests.find("async fn security_headers_are_opt_in_and_content_type_aware()")
security_test_end = route_tests.find("\n#[tokio::test]", security_test_start + 1)
security_test = route_tests[security_test_start:security_test_end]
require(
'api.headers()["content-security-policy"]' in security_test
and "frame-ancestors 'none'" in security_test
and 'api.headers()["x-frame-options"]' in security_test
and '"DENY"' in security_test,
"API regression test enforces browser-facing anti-framing headers",
)
require(
'api.headers().get("permissions-policy").is_none()' in route_tests
and 'api.headers().get("cross-origin-opener-policy").is_none()' in route_tests,
"API regression test omits HTML-only Permissions-Policy and COOP",
)
imap_client_rs = read("src/imap/client.rs")
idle_rs = read("src/realtime/idle.rs")
realtime_sync_rs = read("src/realtime/sync.rs")
require("config.sync.interval_secs" in realtime_sync_rs and "SYNC_INTERVAL_SECS" not in realtime_sync_rs, "periodic IMAP sync interval comes from configuration")
require("config.sync.deep_index_batch_size" in realtime_sync_rs and "DEEP_INDEX_BATCH" not in realtime_sync_rs, "automatic body-cache batch size comes from configuration")
require("config.search.deep_index" in realtime_sync_rs and "force_deep_index" in realtime_sync_rs, "server-wide deep-index setting participates in effective account behavior")
messages_route_rs = read("src/routes/messages/mod.rs")
folders_route_rs = read("src/routes/folders.rs")
folders_js = read("src/web/js/30-folders-tags.js")
reader_js = read("src/web/js/40-messages-reader.js")
realtime_js = read("src/web/js/80-settings-realtime.js")
require('uid_search("UNSEEN")' in imap_client_rs and "folder_status_extended_on_session" in imap_client_rs, "IMAP folder status has an authoritative UNSEEN search fallback")
require('uid_search("UNSEEN")' in idle_rs and "server_unseen" in idle_rs, "IDLE reconciliation counts unseen UIDs instead of using SELECT UNSEEN as a count")
require("tracing::info!(\n tracing::info!(" not in idle_rs, "IDLE handler has no duplicated tracing macro fragment")
require("set_folder_counts" in realtime_sync_rs and "status.unseen != folder.unread_count" in realtime_sync_rs, "periodic sync repairs server folder counters before fast-path skips")
require("MailEvent::FolderStatus" in messages_route_rs and "MessageFlagChange" in messages_route_rs, "message flag mutations publish targeted flag and folder-status events")
folder_counts_route_rs = read("src/routes/folder_counts.rs")
folders_db_rs = read("src/db/folders.rs")
require("pub counter_generation: u64" in folders_db_rs and "expected_generation: u64" in folders_db_rs, "folder counter CAS uses a monotonic database generation")
require("counter_generation_blocks_aba_stale_writer" in folders_db_rs, "folder counter generation has an ABA stale-writer regression test")
require("set_folder_counts_if_unchanged" in folder_counts_route_rs and "for attempt in 0..2" in folder_counts_route_rs, "post-mutation folder counters use CAS with a fresh STATUS retry")
require("reconcile_after_mutation" in messages_route_rs and "counter_snapshot" in messages_route_rs, "message mutations reconcile authoritative counters without stale overwrites")
folder_mgmt_rs = read("src/routes/folder_mgmt.rs")
require("reconcile_after_mutation" in folder_mgmt_rs, "mark-all-read uses the race-safe counter reconciler")
require("get_all_uids_and_flags" in folder_mgmt_rs and "through_uid" in folder_mgmt_rs, "mark-all-read snapshots a UID cutoff so later arrivals stay unread locally")
require("cached_uids" in folder_mgmt_rs and "for uid in &cached_uids" in folder_mgmt_rs, "empty-folder cleanup removes only messages captured before the server mutation")
require("routes_folder_mgmt::Q_002" not in folder_mgmt_rs, "mark-all-read no longer blindly overwrites unread counters with zero")
require("set_folder_counts_if_unchanged" in folders_route_rs, "forced folder refresh protects authoritative counters with compare-and-set")
require("folder_statuses_extended" in folders_route_rs and "pending_sync_count" in folders_route_rs, "folder refresh batches authoritative IMAP status and exposes sync backlog")
require("folder_refresh_uses_server_counts_without_relisting_fresh_folder_tree" in route_tests, "folder refresh regression test covers stale unread count versus authoritative IMAP STATUS")
require("if !cache_fresh" in folders_route_rs and "if query.refresh || !cache_fresh" in folders_route_rs, "folder tree LIST uses TTL while forced refresh updates counters through STATUS")
require("is_noselect" in folders_route_rs and "is_noselect" in realtime_sync_rs, "folder status refresh skips IMAP N\\Noselect hierarchy nodes")
require("folder-count-wide" in folders_js and "folder-count-compact" in folders_js, "folder UI exposes responsive total/unread counters")
index_html = read("src/web/index.html")
require(index_html.find('id="indexing-indicator"') < index_html.find('id="contacts-btn"'), "synchronization/index activity indicator is positioned left of Contacts")
require("folder-separator" in folders_js and ".folder-count .folder-separator" in read("src/web/css/30-mail.css"), "folder total/unread separator is styled independently from the unread value")
require("#message-count" in folders_js and "folderName === state.folder" in folders_js, "authoritative folder totals update the active-folder message count without a list rerender")
require("applyLocalMessageFlags" in reader_js and "updateMessageRowState" in reader_js, "reader flag updates patch individual message rows")
require("applyOptimisticMessageFlag" in reader_js and "rollback = applyOptimisticMessageFlag" in reader_js, "read/unread actions update rows and counters optimistically with guarded rollback")
require("if (payload.eventId)" in realtime_js and "const duplicate = state.realtimeSeenEvents.has(idKey);" in realtime_js and realtime_js.find("if (payload.eventId)") < realtime_js.find("const fingerprint ="), "realtime event dedupe trusts unique eventId before fingerprint fallback")
require("\"eventId\": uuid::Uuid::new_v4().to_string()" in read("src/realtime/ws.rs"), "websocket envelopes carry unique eventId values for lossless client dedupe")
require("MailEvent::FolderAllRead" in read("src/routes/folder_mgmt.rs") and "applyRealtimeFolderAllRead" in realtime_js, "mark-all-read propagates as a targeted realtime event instead of forcing a list reload")
require("update_folder_sync_cursor" in read("src/db/folders.rs") and "Q_031" in read("src/queries/sqlite/queries.rs"), "folder flag-sync cursor is stored independently from authoritative counters")
require('uid_fetch("1:*", "UID FLAGS")' in idle_rs and idle_rs.count("complete.then_some") >= 3, "IDLE fallback reconciles flags and rejects partial IMAP streams")
require(re.search(r"index_message_with_connection\s*\(\s*&transaction", idle_rs) is not None, "IDLE header ingestion enqueues search indexing in the same database transaction")
require("delete_folder_messages(&transaction" in messages_route_rs and "db::header_sync::invalidate(&transaction)" in messages_route_rs and re.search(r"update_folder_sync_status\s*\(\s*&transaction", messages_route_rs) is not None, "UIDVALIDITY reset keeps cache invalidation and folder metadata in one database transaction")
require(
"db::folders::get_folder(conn, &status_name)" in messages_route_rs
and "total_count: folder_state.total_count" in messages_route_rs
and "unread_count: folder_state.unread_count" in messages_route_rs,
"background sync publishes committed folder counters instead of stale network snapshots",
)
require("IMAP cache incomplete" in realtime_js and "Search indexing" in realtime_js, "activity indicator reports IMAP cache and search-index backlog")
require('indicator.classList.toggle("hidden", !busy)' in realtime_js, "activity indicator is hidden whenever synchronization/indexing is idle")
require("setIndexingIndicator(true, 0)" in realtime_js and "setIndexingIndicator(false, 0)" in realtime_js, "manual search reindex drives the activity indicator immediately")
require("MailEvent::IndexingStatus" in read("src/routes/diagnostics.rs") and "Extension(event_bus): Extension<Arc<EventBus>>" in read("src/routes/diagnostics.rs"), "manual search reindex broadcasts indexing activity to connected tabs")
require("deep_index_enabled" in read("src/routes/diagnostics.rs") and "body cache disabled" in realtime_js, "search diagnostics explain whether automatic body caching is enabled")
require('.route("/settings/reset", post(settings::reset_all_settings))' in routes_mod, "authenticated settings reset route is registered")
require("db::settings::reset_all" in read("src/routes/settings.rs"), "settings reset route delegates preference reset to the DB layer")
require('$("#quick-filter-btn")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false)' in read("src/web/js/20-preferences-auth.js"), "Sieve quick-filter UI disappears when Sieve is disabled")
require("state.authPolicy.sieveEnabled === false ? []" in read("src/web/js/60-mail-actions-search.js"), "Sieve context-menu actions disappear when Sieve is disabled")
mail_actions_js = read("src/web/js/60-mail-actions-search.js")
require("function nextMessageTargetAfterRemoval(folder, uid)" in mail_actions_js, "reader computes the adjacent message before removing the current item")
require("state.messages[index + 1] || state.messages[index - 1]" in mail_actions_js, "reader prefers the next message and falls back to the previous item")
require(mail_actions_js.count("await removeCurrentMessageFromView(") >= 2, "move and permanent-delete flows share automatic reader advancement")
require("if (nextTarget) await openMessage(nextTarget.folder, nextTarget.uid);" in mail_actions_js, "reader opens the adjacent message after current-message removal")
require("is_opensearch_backpressure_error" in read("src/search/engine.rs"), "OpenSearch worker classifies backpressure failures")
require("worker pass failed; backing off" in read("src/search/engine.rs"), "OpenSearch worker backs off after failures")
require("ENVIRONMENT: ${ENVIRONMENT:-development}" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL Compose defaults direct HTTP to development cookie mode")
session_db = read("src/session_store/database.rs")
db_pool = read("src/db/pool.rs")
require("crate::db::pool::configure(config).await?" in session_db, "database session store delegates schema initialization to db::pool::configure")
require("global_schema_for_backend" not in session_db, "database session store does not run a second backend schema pass")
require("sqlx::raw_sql(&schema)" not in session_db and "execute_batch(&schema)" not in session_db, "session store avoids duplicate schema DDL for SQLite/MySQL/PostgreSQL")
require("SET LOCAL client_min_messages TO WARNING" in db_pool, "PostgreSQL idempotent startup DDL suppresses expected NOTICE noise locally")
require("Executor as _" in db_pool, "PostgreSQL startup imports SQLx Executor for direct raw DDL execution")
require(".execute(schema.as_str())" in db_pool, "PostgreSQL startup executes schema DDL through Executor instead of raw_sql")
require(not re.search(r"raw_sql\(&schema\)\s*\.execute\(&mut \*tx\)", db_pool), "PostgreSQL startup avoids raw_sql mutable-connection lifetime regression")
require("sqlite_test_configuration_does_not_pin_deleted_tempdir" in db_pool, "SQLite test isolation has a regression test for deleted TempDir reuse")
require("let config = AppConfig {" in db_pool and "let mut config = AppConfig::default();" not in db_pool, "SQLite isolation regression test is Clippy-clean with struct initialization")
sqlite_configure = db_pool.split("DatabaseBackend::Sqlite => {", 1)[1].split("DatabaseBackend::Mysql => {", 1)[0]
require("#[cfg(test)]" in sqlite_configure and "return Ok(());" in sqlite_configure and "#[cfg(not(test))]" in sqlite_configure, "SQLite test configuration does not publish a temporary database path globally")
require("RUST_LOG: ${RUST_LOG:-info,sqlx::postgres::notice=warn}" not in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL Compose no longer depends on a notice-filter workaround")
pgsql_queries = read("src/queries/pgsql/queries.rs")
require("ON CONFLICT(key) DO UPDATE SET value = CAST(user_meta.value AS INTEGER) + 1" in pgsql_queries, "PostgreSQL header-sync UPSERT qualifies the existing target value")
require("tenant_rewriter_prefixes_header_sync_target_qualifier" in db_pool, "tenant SQL rewriter has a regression test for qualified header-sync value")
mysql_queries = read("src/queries/mysql/queries.rs")
require("ON DUPLICATE KEY UPDATE value = CAST(value AS SIGNED) + 1" in mysql_queries, "MySQL header-sync uses its non-EXCLUDED duplicate-key form")
# PostgreSQL UPSERT audit: any read of an INSERT target column inside
# ON CONFLICT ... DO UPDATE must be qualified as either excluded.<column>
# or <target_table>.<column>. PostgreSQL otherwise treats names such as
# `value` as ambiguous between the existing target row and EXCLUDED.
def postgres_upsert_ambiguities(source: str) -> list[str]:
const_re = re.compile(
r'pub const\s+(\w+)\s*:\s*&str\s*=\s*(r#".*?"#|"(?:\\.|[^"\\])*")\s*;',
re.S,
)
problems: list[str] = []
for const_name, literal in const_re.findall(source):
if literal.startswith('r#"'):
sql = literal[3:-2]
else:
sql = bytes(literal[1:-1], 'utf-8').decode('unicode_escape')
if not re.search(r'ON\s+CONFLICT.*DO\s+UPDATE\s+SET', sql, re.I | re.S):
continue
insert = re.search(
r'INSERT\s+INTO\s+([A-Za-z_][\w]*)\s*\((.*?)\)\s*VALUES',
sql,
re.I | re.S,
)
if insert is None:
problems.append(f'{const_name}: unable to parse INSERT target')
continue
table = insert.group(1)
columns = [column.strip().strip('"') for column in insert.group(2).split(',')]
update = re.split(r'DO\s+UPDATE\s+SET', sql, maxsplit=1, flags=re.I)[1]
scan = re.sub(r"'(?:''|[^'])*'", '', update)
for column in columns:
candidate = re.sub(
rf'\b(?:excluded|{re.escape(table)})\s*\.\s*{re.escape(column)}\b',
'',
scan,
flags=re.I,
)
candidate = re.sub(
rf'(?:(?<=^)|(?<=,))\s*{re.escape(column)}\s*=',
'=',
candidate,
flags=re.I,
)
if re.search(rf'(?<!\.)\b{re.escape(column)}\b', candidate, re.I):
problems.append(f'{const_name}: unqualified target read `{column}` in {table} UPSERT')
return problems
pgsql_upsert_ambiguities = postgres_upsert_ambiguities(pgsql_queries)
require(
not pgsql_upsert_ambiguities,
'PostgreSQL ON CONFLICT target-column reads are fully qualified'
+ (f" ({'; '.join(pgsql_upsert_ambiguities)})" if pgsql_upsert_ambiguities else ''),
)
# Parse all maintained TOML examples.
toml_files = [
"Cargo.toml",
"rust-toolchain.toml",
"config.example.toml",
"config.dev.toml",
"packaging/rustbox.toml",
"docker/configs/single-sqlite.toml",
"docker/configs/postgres-s3-opensearch.toml",
"docker/configs/mysql-nfs-opensearch.toml",
"docker/configs/ha-garage.toml",
]
for rel in toml_files:
try:
tomllib.loads(read(rel))
ok(f"TOML parses: {rel}")
except Exception as exc:
errors.append(f"TOML parse failed: {rel}: {exc}")
# Parse Docker Compose YAML and validate referenced config paths.
compose_files = sorted((ROOT / "docker").glob("compose-*.yml"))
require(len(compose_files) >= 4, "four Docker Compose deployment variants exist")
if yaml is None:
errors.append("PyYAML is required for Docker Compose static validation")
else:
for path in compose_files:
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
require(isinstance(data, dict) and "services" in data, f"Compose parses with services: {path.name}")
services = data.get("services", {})
for service_name, service in services.items():
if service_name != "rustbox" and not service_name.startswith("rustbox-"):
continue
for volume in service.get("volumes", []) or []:
if isinstance(volume, str) and volume.startswith("./configs/"):
config_rel = volume.split(":", 1)[0].removeprefix("./")
require((ROOT / "docker" / config_rel).is_file(), f"Compose config mount exists: {path.name}/{service_name} -> {config_rel}")
except Exception as exc:
errors.append(f"Compose parse failed: {path.name}: {exc}")
single = tomllib.loads(read("docker/configs/single-sqlite.toml"))
pg_s3 = tomllib.loads(read("docker/configs/postgres-s3-opensearch.toml"))
my_nfs = tomllib.loads(read("docker/configs/mysql-nfs-opensearch.toml"))
require(single["cluster_mode"] is False and single["storage"]["backend"] == "filesystem", "single-node Compose uses local filesystem storage")
require(pg_s3["cluster_mode"] is True and "data_dir" not in pg_s3, "PostgreSQL/S3 cluster config omits data_dir")
require(pg_s3["storage"]["backend"] == "s3" and pg_s3["search"]["backend"] == "opensearch", "PostgreSQL cluster uses S3 + OpenSearch")
require(my_nfs["cluster_mode"] is True and "data_dir" not in my_nfs, "MySQL/NFS cluster config omits data_dir")
require(my_nfs["storage"]["backend"] == "filesystem" and my_nfs["storage"]["shared"] is True, "MySQL cluster marks POSIX/NFS storage shared")
require(my_nfs["search"]["backend"] == "opensearch", "MySQL/NFS cluster uses OpenSearch")
dev_config = tomllib.loads(read("config.dev.toml"))
prod_config = tomllib.loads(read("config.example.toml"))
packaged_config = tomllib.loads(read("packaging/rustbox.toml"))
require(dev_config["search"]["deep_index"] is True, "development config enables automatic full-body cache/index")
require(prod_config["search"]["deep_index"] is False and packaged_config["search"]["deep_index"] is False, "production configs keep automatic full-body cache/index disabled")
require(prod_config.get("pgp_enabled") is True and "pgp_enabled" not in prod_config.get("search", {}), "production example keeps root-level settings outside TOML search table")
require(packaged_config.get("database_backend") == "sqlite" and packaged_config.get("max_attachment_size_mb") == 50 and packaged_config.get("sieve_tls_mode") == "none" and "database_backend" not in packaged_config.get("search", {}), "packaged production config preserves top-level runtime settings outside TOML search table")
for diagnostics_config in [
"config.dev.toml",
"config.example.toml",
"packaging/rustbox.toml",
"docker/configs/single-sqlite.toml",
"docker/configs/postgres-s3-opensearch.toml",
"docker/configs/ha-garage.toml",
"docker/configs/mysql-nfs-opensearch.toml",
]:
parsed = tomllib.loads(read(diagnostics_config))
diagnostics = parsed.get("diagnostics_endpoint", {})
require(
diagnostics.get("enabled") is False
and diagnostics.get("allowed_networks") == ["127.0.0.1/32", "::1/128"]
and diagnostics.get("path") == "/api/system/diagnostics",
f"diagnostics config is disabled and loopback-only by default: {diagnostics_config}",
)
require(dev_config["sync"]["interval_secs"] == 30 and dev_config["sync"]["deep_index_batch_size"] == 10, "development config documents 30s/10-message sync defaults")
require(prod_config["sync"]["interval_secs"] == 30 and prod_config["sync"]["deep_index_batch_size"] == 10, "production example documents 30s/10-message sync defaults")
require(packaged_config["sync"]["interval_secs"] == 30 and packaged_config["sync"]["deep_index_batch_size"] == 10, "packaged production config documents 30s/10-message sync defaults")
ha = tomllib.loads(read("docker/configs/ha-garage.toml"))
require(ha["cluster_mode"] is True and ha["session_backend"] == "redis", "HA/Garage config enables cluster mode with Redis-compatible sessions")
require(ha["redis_url"] == "redis://valkey:6379/", "HA/Garage config targets Valkey for shared sessions")
require(ha["storage"]["backend"] == "s3" and ha["storage"]["endpoint"] == "http://garage:3900", "HA/Garage config stores blobs in Garage S3")
require(ha["search"]["backend"] == "opensearch", "HA/Garage config uses OpenSearch")
require(ha["search"]["deep_index"] is True, "development HA example enables automatic deep index")
require(single["search"]["deep_index"] is False and pg_s3["search"]["deep_index"] is False and my_nfs["search"]["deep_index"] is False, "production Docker configs disable automatic full-body cache/index by default")
require(all(cfg["sync"]["interval_secs"] > 0 and cfg["sync"]["deep_index_batch_size"] > 0 for cfg in (single, pg_s3, my_nfs, ha)), "Docker configs provide positive IMAP sync/deep-index tuning values")
dockerfile = read("docker/Dockerfile")
require("FROM rust:1.99.1-trixie AS builder" in dockerfile, "Docker builder pins Rust 1.99.1 on Debian Trixie")
require("FROM debian:trixie-slim" in dockerfile, "Docker runtime uses Debian Trixie slim")
require("libssl3t64" in dockerfile and "libssl3 " not in dockerfile, "Docker runtime uses the Debian Trixie OpenSSL runtime package")
require("bookworm" not in dockerfile.lower(), "RustBox Dockerfile no longer uses Debian Bookworm")
require("postgres:17-trixie" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL/S3 Compose uses PostgreSQL 17 Trixie")
require("bookworm" not in read("docker/compose-postgres-s3-opensearch.yml").lower(), "PostgreSQL/S3 Compose no longer uses Bookworm")
ha_compose = read("docker/compose-ha-garage.yml")
require("postgres:17-trixie" in ha_compose, "HA Compose uses PostgreSQL 17 Trixie")
require("bookworm" not in ha_compose.lower(), "HA Compose no longer uses Bookworm")
require("rustbox-1:" in ha_compose and "rustbox-2:" in ha_compose, "HA Compose defines two RustBox application nodes")
for service in ["postgres:", "valkey:", "opensearch:", "garage:", "envoy:"]:
require(service in ha_compose, f"HA Compose includes {service.removesuffix(':')}")
require("valkey/valkey:9.1.3-alpine" in ha_compose, "HA Compose pins Valkey 9.1.3")
require("opensearchproject/opensearch:3.9.0" in ha_compose, "HA Compose pins OpenSearch 3.9.0")
require("dxflrs/garage:v2.5.0" in ha_compose, "HA Compose pins Garage 2.5.0")
require("envoyproxy/envoy:v1.40.2" in ha_compose, "HA Compose pins Envoy 1.40.2")
require("X-RustBox-Backend" in read("docker/envoy/envoy.yaml"), "Envoy exposes the selected RustBox backend response header")
require("address: rustbox-1" in read("docker/envoy/envoy.yaml") and "address: rustbox-2" in read("docker/envoy/envoy.yaml"), "Envoy balances both RustBox nodes")
require('sieve_enabled = false' in read("docker/configs/ha-garage.toml"), "HA example disables optional Sieve by default")
require('security_headers_enabled = false' in read("docker/configs/ha-garage.toml"), "HA example keeps optional security headers disabled by default")
require("type: nfs" in read("docker/compose-mysql-nfs-opensearch.yml"), "NFS Compose uses a real NFS volume driver")
require("docker.io/pgsty/silo:RELEASE.2026-10-26T00-00-00Z" in read("docker/compose-postgres-s3-opensearch.yml"), "S3 Compose pins the maintained SILO integration image")
require("docker.io/pgsty/mc:RELEASE.2026-10-26T00-00-00Z" in read("docker/compose-postgres-s3-opensearch.yml"), "S3 Compose pins the matching SILO client image")
require('test: ["CMD", "silo", "healthcheck", "ready"]' in read("docker/compose-postgres-s3-opensearch.yml"), "S3 Compose waits on SILO readiness before bucket initialization")
require("endpoint = \"http://s3:9000\"" in read("docker/configs/postgres-s3-opensearch.toml"), "S3 config targets the generic Compose S3 service")
for rel in ["docker/compose-postgres-s3-opensearch.yml", "docker/compose-mysql-nfs-opensearch.yml"]:
require("opensearchproject/opensearch:3.9.0" in read(rel), f"Compose pins OpenSearch 3.9.0: {rel}")
require("opensearchproject/opensearch:latest" not in read(rel), f"Compose avoids moving OpenSearch latest tag: {rel}")
require('DISABLE_INSTALL_DEMO_CONFIG: "true"' in read(rel), f"Compose disables OpenSearch demo security bootstrap: {rel}")
require('DISABLE_SECURITY_PLUGIN: "true"' in read(rel), f"Compose explicitly disables OpenSearch security plugin for local integration: {rel}")
for rel in ["docker/compose-postgres-s3-opensearch.yml", "docker/compose-mysql-nfs-opensearch.yml"]:
match = re.search(r"SESSION_ENCRYPTION_KEY:\s*\$\{SESSION_ENCRYPTION_KEY:-([^}]+)\}", read(rel))
require(match is not None, f"Compose has SESSION_ENCRYPTION_KEY fallback: {rel}")
if match:
try:
decoded = base64.b64decode(match.group(1), validate=True)
require(len(decoded) == 32, f"Compose fallback session key decodes to 32 bytes: {rel}")
except Exception as exc:
errors.append(f"invalid Compose fallback session key in {rel}: {exc}")
# SQLite fresh-schema execution.
schema = read("migrations/sqlite/schema.sql")
global_sql = extract_section(schema, "-- RUSTBOX:GLOBAL-BEGIN", "-- RUSTBOX:GLOBAL-END")
tenant_sql = extract_section(schema, "-- RUSTBOX:TENANT-BEGIN", "-- RUSTBOX:TENANT-END").replace("{{p}}", "u_test__")
try:
conn = sqlite3.connect(":memory:")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript(global_sql)
conn.executescript(tenant_sql)
columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__draft_attachments)")}
require("checksum_sha256" in columns, "fresh SQLite schema has attachment checksum metadata")
outbox = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='u_test__search_outbox'").fetchone()
require(outbox is not None, "fresh SQLite schema has search_outbox")
outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__search_outbox)")}
require("claimed_until" in outbox_columns, "fresh SQLite search_outbox separates worker lease from retry availability")
blob_outbox = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='u_test__blob_delete_outbox'").fetchone()
require(blob_outbox is not None, "fresh SQLite schema has blob_delete_outbox")
blob_outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__blob_delete_outbox)")}
require({"storage_key", "attempts", "available_at", "claimed_until", "last_error"}.issubset(blob_outbox_columns), "fresh SQLite blob deletion outbox has retry/lease state")
folder_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__folders)")}
require("counter_generation" in folder_columns, "fresh SQLite schema has V048 folder counter generation")
sqlite_header_sync = "INSERT INTO u_test__user_meta (key, value) VALUES ('header_sync_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1"
conn.execute(sqlite_header_sync)
conn.execute(sqlite_header_sync)
sqlite_generation = conn.execute("SELECT value FROM u_test__user_meta WHERE key='header_sync_generation'").fetchone()
require(sqlite_generation == ("2",), "SQLite header-sync UPSERT remains unambiguous and increments generation")
account_default = conn.execute("PRAGMA table_info(rustbox_accounts)").fetchall()
version_default = next((row[4] for row in account_default if row[1] == "schema_version"), None)
require(str(version_default).strip("'\"") == str(BASE_SCHEMA_VERSION), f"fresh global compatibility baseline is {migration_label(BASE_SCHEMA_VERSION)} before tenant registration")
display_columns = {row[1]: row for row in conn.execute("PRAGMA table_info(u_test__display_preferences)")}
require("adaptive_email_colors" in display_columns and str(display_columns["adaptive_email_colors"][4]) == "0", "fresh schema defaults adaptive HTML-mail colors off")
mail_columns = {row[1]: row for row in conn.execute("PRAGMA table_info(u_test__mail_preferences)")}
require("message_loading_mode" in mail_columns and str(mail_columns["message_loading_mode"][4]).strip("\'\"") == "pagination", "fresh schema defaults mailbox loading mode to pagination")
conn.close()
except Exception as exc:
errors.append(f"fresh SQLite schema execution failed: {exc}")
# Migration/query parity across SQL engines. Only migrations newer than the
# consolidated baseline are active. Historical/squashed V*.sql files may remain
# in the source tree and are intentionally ignored by the runtime parity checks.
def active_migrations(dialect: str) -> list[tuple[int, str]]:
return [
(version, filename)
for version, filename in MIGRATIONS[dialect]
if version > BASE_SCHEMA_VERSION
]
reference_migrations = active_migrations("sqlite")
for _, dialect in MIGRATION_DIALECTS:
dialect_migrations = active_migrations(dialect)
require(
dialect_migrations == reference_migrations,
f"active migration filename/version parity matches SQLite for {dialect}",
)
schema_text = read(f"migrations/{dialect}/schema.sql")
expected_schema_banner = (
f"Squashed compatibility baseline: {migration_label(BASE_SCHEMA_VERSION)}; "
f"current schema: {migration_label(LATEST_SCHEMA_VERSION)}."
)
require(
expected_schema_banner in schema_text
and "counter_generation" in schema_text
and "blob_delete_outbox" in schema_text
and "compact_wide_rows" in schema_text
and "signature_insert_mode" in schema_text
and "adaptive_email_colors" in schema_text
and "message_loading_mode" in schema_text
and "pagination" in schema_text,
f"fresh {dialect} schema contains the consolidated baseline and current preference columns",
)
queries = read(f"src/queries/{dialect}/queries.rs")
require("adaptive_email_colors" in queries, f"display-preference query includes adaptive_email_colors for {dialect}")
require("message_loading_mode" in queries, f"mail-preference query includes message_loading_mode for {dialect}")
folder_block = re.search(r"pub mod db_folders\s*\{(.*?)\n\}", queries, re.S)
require(folder_block is not None and "Q_032" in folder_block.group(1) and "counter_generation = counter_generation + 1" in folder_block.group(1), f"folder counter CAS increments generation for {dialect}")
block_match = re.search(r"pub mod db_search_outbox\s*\{(.*?)\n\}", queries, re.S)
require(block_match is not None, f"search outbox query module exists for {dialect}")
if block_match:
qnames = set(re.findall(r"pub const (Q_\d{3})", block_match.group(1)))
require(qnames == {f"Q_{i:03d}" for i in range(1, 8)}, f"search outbox Q001-Q007 parity for {dialect}")
blob_block = re.search(r"pub mod db_blob_delete_outbox\s*\{(.*?)\n\}", queries, re.S)
require(blob_block is not None, f"blob deletion outbox query module exists for {dialect}")
if blob_block:
blob_qnames = set(re.findall(r"pub const (Q_\d{3})", blob_block.group(1)))
require(blob_qnames == {f"Q_{i:03d}" for i in range(1, 6)}, f"blob deletion outbox Q001-Q005 parity for {dialect}")
# Runtime migration catalogs are intentionally not statically mirrored against
# on-disk V*.sql files. They are release-maintained data and are validated by
# runtime migration tests instead of brittle file-list assertions.
require(
"latest_schema_version" in pool
and ".last()" in pool
and "unwrap_or(BASE_SCHEMA_VERSION)" in pool,
"runtime schema version is derived from the migration catalog",
)
require(
"latest_supported_sqlite = SQLITE_MIGRATIONS" in pool
and "if version > latest_supported_sqlite" in pool,
f"legacy SQLite import accepts {migration_label(BASE_SCHEMA_VERSION)}..{migration_label(LATEST_SCHEMA_VERSION)} and rejects newer schemas",
)
# Historical regression for the preference migration. This introduction version is
# intentionally fixed; do not bump it when V052/V053/... are added. Once the baseline
# reaches this version, the check automatically switches to fresh-schema validation.
PREFERENCE_FEATURE_INTRO_VERSION = 51
preference_migration_version = PREFERENCE_FEATURE_INTRO_VERSION
preference_migration = migration_containing(
"sqlite", "adaptive_email_colors", "message_loading_mode", "pagination"
)
if BASE_SCHEMA_VERSION < preference_migration_version:
require(
preference_migration is not None and preference_migration[0] == preference_migration_version,
f"{migration_label(preference_migration_version)} preference migration is present while baseline is older",
)
if preference_migration is not None and preference_migration[0] == preference_migration_version:
_, preference_migration_file = preference_migration
try:
conn = sqlite3.connect(":memory:")
conn.executescript("""
CREATE TABLE u_prod__display_preferences (
id INTEGER PRIMARY KEY,
theme TEXT NOT NULL DEFAULT 'system',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE u_prod__mail_preferences (
id INTEGER PRIMARY KEY,
messages_per_page INTEGER NOT NULL DEFAULT 300,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO u_prod__display_preferences(id) VALUES(1);
INSERT INTO u_prod__mail_preferences(id) VALUES(1);
""")
conn.executescript(
read(f"migrations/sqlite/{preference_migration_file}").replace("{{p}}", "u_prod__")
)
migrated_display = {row[1]: row for row in conn.execute("PRAGMA table_info(u_prod__display_preferences)")}
migrated_mail = {row[1]: row for row in conn.execute("PRAGMA table_info(u_prod__mail_preferences)")}
label = migration_label(preference_migration_version)
require("adaptive_email_colors" in migrated_display and str(migrated_display["adaptive_email_colors"][4]) == "0", f"SQLite {label} adds adaptive_email_colors")
require("message_loading_mode" in migrated_mail and str(migrated_mail["message_loading_mode"][4]).strip("'\"") == "pagination", f"SQLite {label} adds pagination mode")
values = conn.execute("SELECT adaptive_email_colors FROM u_prod__display_preferences WHERE id=1").fetchone()
mode = conn.execute("SELECT message_loading_mode FROM u_prod__mail_preferences WHERE id=1").fetchone()
require(values == (0,) and mode == ("pagination",), f"SQLite {label} backfills defaults for existing preference rows")
conn.close()
except Exception as exc:
errors.append(f"SQLite {migration_label(preference_migration_version)} migration execution failed: {exc}")
else:
require(
"adaptive_email_colors" in read("migrations/sqlite/schema.sql")
and "message_loading_mode" in read("migrations/sqlite/schema.sql"),
f"preference columns remain in the squashed {migration_label(BASE_SCHEMA_VERSION)}+ schema",
)
# Tenant/import table parity and transaction-bound mutation helpers.
def const_string_array(text: str, name: str) -> list[str]:
match = re.search(rf"const {name}: &\[&str\] = &\[(.*?)\];", text, re.S)
return re.findall(r'"([^"\n]+)"', match.group(1)) if match else []
tenant_tables = const_string_array(pool, "TENANT_TABLES")
import_tables = const_string_array(pool, "IMPORT_TABLES")
require(
tenant_tables and sorted(tenant_tables) == sorted(import_tables),
"tenant and legacy-import table sets stay in sync",
)
messages_rs = read("src/db/messages.rs")
routes_messages_rs = read("src/routes/messages/mod.rs")
require(
"pub fn remove_after_mutation_in_transaction(" in messages_rs
and "remove_after_mutation_in_transaction(&transaction, folder, uid)?;" in messages_rs
and "db::messages::remove_after_mutation_in_transaction(" in routes_messages_rs
and "db::messages::remove_after_mutation(&transaction" not in routes_messages_rs,
"move mutation reuses its caller transaction instead of nesting a second transaction",
)
# Exercise the actual SQLite search-outbox query constants against the current tenant schema.
try:
sqlite_queries = read("src/queries/sqlite/queries.rs")
outbox_block = re.search(r"pub mod db_search_outbox\s*\{(.*?)\n\}", sqlite_queries, re.S)
if outbox_block is None:
raise RuntimeError("db_search_outbox module not found")
queries = {
name: sql.replace("search_outbox", "u_test__search_outbox")
for name, sql in re.findall(r'pub const (Q_\d{3}): &str = "([^"]+)";', outbox_block.group(1))
}
conn = sqlite3.connect(":memory:")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript(tenant_sql)
conn.execute(queries["Q_001"], ("doc-1", "gen-1", "upsert", 7, "INBOX", "{}", 1000))
ready = conn.execute(queries["Q_002"], (1000, 10)).fetchall()
require(len(ready) == 1 and ready[0][0:3] == ("doc-1", "gen-1", "upsert"), "SQLite search outbox enqueue/list query roundtrip works")
first_claim = conn.execute(queries["Q_006"], ("doc-1", "gen-1", 1060, 1000)).rowcount
second_claim = conn.execute(queries["Q_006"], ("doc-1", "gen-1", 1060, 1000)).rowcount
require(first_claim == 1 and second_claim == 0, "SQLite search outbox lease claim is exclusive")
conn.execute(queries["Q_001"], ("doc-1", "gen-2", "delete", 7, "INBOX", None, 1001))
requeued = conn.execute(
"SELECT generation, event_type, attempts, available_at, claimed_until, dead_letter, created_at, updated_at "
"FROM u_test__search_outbox WHERE id='doc-1'"
).fetchone()
require(
requeued == ("gen-2", "delete", 0, 0, 1060, 0, 1001, 1001),
"SQLite search outbox re-enqueue resets retry state while preserving an active worker lease",
)
require(
conn.execute(queries["Q_002"], (1001, 10)).fetchall() == [],
"SQLite replacement event stays hidden until the active lease expires",
)
stale_delete = conn.execute(queries["Q_003"], ("doc-1", "gen-1")).rowcount
current_delete = conn.execute(queries["Q_003"], ("doc-1", "gen-2")).rowcount
require(stale_delete == 0 and current_delete == 1, "SQLite search outbox generation guard rejects stale acknowledgements")
conn.execute(queries["Q_001"], ("doc-2", "gen-3", "delete", 9, "Trash", None, 1002))
failure_update = conn.execute(queries["Q_004"], ("doc-2", "gen-3", 1, 2000, 1, "boom", 1003)).rowcount
stats = conn.execute(queries["Q_005"]).fetchone()
claimed_after_failure = conn.execute("SELECT claimed_until FROM u_test__search_outbox WHERE id='doc-2'").fetchone()[0]
require(failure_update == 1 and stats[0] == 1 and stats[1] == 1 and claimed_after_failure == 0, "SQLite search outbox retry/dead-letter resets worker lease and updates stats")
conn.execute(queries["Q_001"], ("doc-old", "gen-old", "delete", 10, "Archive", None, 900))
cleared = conn.execute(queries["Q_007"], (1000,)).rowcount
require(cleared == 1, "SQLite search outbox can clear pre-reindex superseded events")
conn.close()
except Exception as exc:
errors.append(f"SQLite search-outbox query execution failed: {exc}")
# Storage/search architecture invariants.
config_rs = read("src/config.rs")
storage_rs = read("src/storage/mod.rs")
search_rs = read("src/search/engine.rs")
require("if self.cluster_mode && !data_dir_explicit" in config_rs and "self.data_dir.clear()" in config_rs, "implicit data_dir is disabled in cluster mode")
require("cluster_shared_data_dir" not in config_rs and "search_index_backend" not in config_rs, "legacy top-level storage/search switches are removed")
require('self.storage.backend.trim().to_ascii_lowercase()' in config_rs and 'self.search.backend.trim().to_ascii_lowercase()' in config_rs, "storage/search backends come only from their dedicated config sections")
require('"tantivy" => "local_tantivy"' not in config_rs, "legacy Tantivy backend alias is removed")
require('"postgres"' not in config_rs.split("#[cfg(test)]", 1)[0] and '"postgresql"' not in config_rs.split("#[cfg(test)]", 1)[0] and '"postgres"' not in read("src/db/pool.rs").split("#[cfg(test)]", 1)[0] and '"postgresql"' not in read("src/db/pool.rs").split("#[cfg(test)]", 1)[0], "database backend uses canonical sqlite/mysql/pgsql names only")
require("cluster_shared_data_dir" not in read("config.example.toml") and "search_index_backend" not in read("config.example.toml"), "example config contains no pre-0.2 compatibility switches")
require("legacy_filesystem_root" not in storage_rs and "legacy_path(" not in storage_rs, "blob storage has no legacy attachment read-through path")
require('"filesystem" | "s3"' in config_rs or '"filesystem", "s3"' in config_rs, "configuration accepts filesystem and S3 storage")
require("storage.shared" in config_rs, "cluster-safe shared POSIX storage is capability-gated")
require("AmazonS3Builder" in storage_rs and "BufWriter" in storage_rs, "S3 backend uses streaming object-store writer")
require("with_retry" in storage_rs and "with_checksum_algorithm" in storage_rs, "S3 backend configures retries and checksums")
require("attachment_key" in storage_rs and "users/{user_hash}/drafts/{draft_id}/attachments/{attachment_id}" in storage_rs, "attachments use logical storage keys")
require("enqueue_unreferenced_delete(&conn, destination.as_ref(), &target_key).await" in storage_rs, "attachment migration durably queues failed destination blob cleanup")
require("let _ = upload.abort().await" in storage_rs and "failed reading source" in storage_rs, "attachment migration aborts a destination upload if source streaming fails")
require("if let Err(error) = crate::db::drafts::update_attachment_storage" in storage_rs and "Failed to queue storage migration cleanup" in storage_rs, "attachment migration queues destination cleanup when SQL pointer update fails")
require("OpenSearchBackend" in search_rs, "OpenSearch backend is implemented")
require("reqwest::blocking" not in search_rs and 'features = ["json", "rustls-tls"]' in read("Cargo.toml"), "OpenSearch uses the async reqwest client without blocking feature")
require("async fn request(" in search_rs and "request.send().await" in search_rs, "OpenSearch HTTP requests are asynchronous")
require('json!({"term": {"user_hash": user_hash}})' in search_rs, "OpenSearch queries enforce server-side tenant filter")
require("process_search_outbox" in search_rs and "claim(&conn" in search_rs, "OpenSearch worker uses durable claimed outbox events")
require("fn event_lease_secs(&self) -> u64" in search_rs and "let lease_secs = backend.event_lease_secs();" in search_rs and "claim(&conn, &event, 60)" not in search_rs, "OpenSearch outbox lease is derived from HTTP timeout/failover budget instead of a fixed 60 seconds")
require("opensearch_event_lease_covers_timeout_and_failover_budget" in search_rs, "OpenSearch lease budget has a source-level unit regression test")
require("claimed_until" in read("migrations/sqlite/schema.sql") and "reenqueue_preserves_active_claim_lease" in read("src/db/search_outbox.rs"), "outbox re-enqueue cannot steal an active worker lease")
blob_delete_rs = read("src/db/blob_delete_outbox.rs")
attachments_rs = read("src/routes/attachments.rs")
send_rs = read("src/routes/send.rs")
require("mark_failure" in blob_delete_rs and "min(3600)" in blob_delete_rs and "max_attempts" not in blob_delete_rs and "dead_letter" not in read("migrations/sqlite/schema.sql").split("CREATE TABLE {{p}}blob_delete_outbox", 1)[1].split("CREATE TABLE {{p}}search_outbox", 1)[0], "blob deletion outbox retries indefinitely with capped backoff and no permanent dead-letter")
require("unchecked_transaction()" in attachments_rs and "blob_delete_outbox::enqueue(&transaction" in attachments_rs and "delete_draft_attachment(&transaction" in attachments_rs, "single attachment deletion atomically queues blob cleanup with metadata deletion")
require("blob_delete_outbox::enqueue(&transaction" in drafts_rs and "delete_draft(&transaction" in drafts_rs, "whole-draft deletion atomically queues every attachment blob before metadata cascade")
require("blob_delete_outbox::enqueue(&transaction" in send_rs and "delete_draft(&transaction" in send_rs, "post-send draft cleanup atomically queues attachment blobs")
require("start_blob_delete_worker" in storage_rs and "process_blob_delete_outbox" in storage_rs and "start_blob_delete_worker(config.clone(), storage.clone())" in read("src/main.rs"), "durable blob deletion worker is wired at startup")
require('let key = "_health/probe".to_string();' in storage_rs, "storage health probe uses a fixed key and cannot accumulate unique orphan probe objects")
require("index_messages_batch_with_connection" in search_rs and "enqueue_upsert_on" in search_rs, "OpenSearch outbox supports caller-owned SQL transactions")
require("enqueue_participates_in_caller_transaction" in read("src/db/search_outbox.rs"), "transactional outbox rollback has a unit test")
require("search reindex" in read("README.md") and "run_search_reindex" in read("src/main.rs"), "full search reindex command is documented and wired")
require("clear_before(&conn, reindex_started_at)" in read("src/main.rs") and "pub fn clear_before(" in read("src/db/search_outbox.rs"), "full reindex removes only superseded pre-reindex outbox events")
require("quiesce search workers" in read("docs/STORAGE_SEARCH_OPERATIONS.md"), "cluster reindex documentation requires a controlled maintenance window")
require("self.backend.refresh().await?" in search_rs, "OpenSearch full reindex forces refresh before reporting completion")
require("pub async fn document_count(&self) -> Result<usize, String>" in search_rs, "search document-count diagnostics propagate backend errors")
require(".document_count()\n .await\n .map_err(AppError::InternalError)?" in read("src/routes/diagnostics.rs"), "diagnostics route does not convert OpenSearch count failures into zero")
idle_rs = read("src/realtime/idle.rs")
require("fn delete_cached_message(" in idle_rs and "delete_message_with_connection(&transaction, uid, folder)" in idle_rs, "IMAP IDLE cache deletion writes search outbox in the same DB transaction")
require("search_engine.clone()," in read("src/realtime/ws.rs"), "websocket IDLE wiring passes the shared search engine")
require("cargo generate-lockfile && cargo build --release --locked" in read("docker/Dockerfile"), "Docker release build generates and honors a dependency lockfile")
require("PostgreSQL + SILO (S3-compatible) + OpenSearch" in read("CHANGELOG.md"), "0.2.0 changelog matches the pinned S3-compatible Compose service")
require("docs/RUSTBOX_PLAN.md" not in read("docs/IMPLEMENTATION_STATUS.md"), "implementation status does not reference a missing release-plan file")
require("intentionally run one RustBox application container" in read("docker/README.md"), "Docker docs distinguish shared backing topology from multi-app load balancing")
attachments_rs = read("src/routes/attachments.rs")
require(".begin_upload(&key)" in attachments_rs and ".get(&attachment.storage_key)" in attachments_rs and "storage::process_queued_delete" in attachments_rs, "attachment HTTP routes use BlobStorage")
require("tokio::fs::" not in attachments_rs, "attachment HTTP routes no longer access local filesystem directly")
require("pub(crate) struct AttachMessageSourceRequest" in attachments_rs, "forward-as-attachment request type matches route handler crate visibility")
attachment_enqueue = attachments_rs.find("blob_delete_outbox::enqueue(&transaction")
attachment_db_delete = attachments_rs.find("delete_draft_attachment(&transaction", attachment_enqueue)
attachment_commit = attachments_rs.find(".commit()", attachment_db_delete)
attachment_blob_delete = attachments_rs.find("storage::process_queued_delete", attachment_commit)
require(0 <= attachment_enqueue < attachment_db_delete < attachment_commit < attachment_blob_delete, "attachment delete commits metadata removal and cleanup enqueue atomically before physical blob deletion")
drafts_rs = read("src/routes/drafts.rs")
draft_enqueue = drafts_rs.find("blob_delete_outbox::enqueue(&transaction")
draft_db_delete = drafts_rs.find("delete_draft(&transaction", draft_enqueue)
draft_commit = drafts_rs.find(".commit()", draft_db_delete)
draft_blob_delete = drafts_rs.find("storage::process_queued_delete", draft_commit)
require(0 <= draft_enqueue < draft_db_delete < draft_commit < draft_blob_delete, "draft delete commits metadata cascade and cleanup enqueue atomically before physical blob deletion")
send_rs = read("src/routes/send.rs")
send_enqueue = send_rs.find("blob_delete_outbox::enqueue(&transaction")
send_db_delete = send_rs.find("delete_draft(&transaction", send_enqueue)
send_commit = send_rs.find("transaction.commit()", send_db_delete)
send_blob_delete = send_rs.find("storage::process_queued_delete", send_commit)
require(0 <= send_enqueue < send_db_delete < send_commit < send_blob_delete, "post-send cleanup commits metadata removal and cleanup enqueue atomically before physical blob deletion")
# Repository/source-package hygiene independent of Cargo.
source_files = [path for path in ROOT.rglob("*") if path.is_file() and ".git" not in path.parts and "target" not in path.parts]
conflict_marker = re.compile(r"(?m)^(<<<<<<<|=======|>>>>>>>)")
conflicted = []
for path in source_files:
if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".zip", ".gz"}:
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if conflict_marker.search(text):
conflicted.append(str(path.relative_to(ROOT)))
require(not conflicted, f"no unresolved merge-conflict markers ({', '.join(conflicted) if conflicted else 'clean'})")
require(not any(path.suffix in {".ts", ".tsx"} for path in source_files), "source package contains no TypeScript files")
manifest = [line.strip() for line in read("src/web/js/manifest.txt").splitlines() if line.strip() and not line.lstrip().startswith("#")]
missing_manifest = [entry for entry in manifest if not (ROOT / "src/web/js" / entry).is_file()]
require(not missing_manifest, f"browser JS manifest entries exist ({', '.join(missing_manifest) if missing_manifest else 'complete'})")
include_paths = []
for path in ROOT.glob("src/**/*.rs"):
text = path.read_text(encoding="utf-8")
for match in re.finditer(r'include_(?:str|bytes)!\(\s*"([^"]+)"\s*\)', text):
include_paths.append((path, match.group(1)))
missing_includes = []
for source, relative in include_paths:
candidate = (source.parent / relative).resolve()
if not candidate.is_file():
missing_includes.append(f"{source.relative_to(ROOT)} -> {relative}")
require(not missing_includes, f"Rust include_str/include_bytes targets exist ({'; '.join(missing_includes) if missing_includes else 'complete'})")
# Shell syntax checks are useful offline and deterministic.
try:
shell_files = [ROOT / "dev.sh", *sorted((ROOT / "scripts").glob("*.sh")), ROOT / "docker/garage/init/init.sh"]
subprocess.run(["bash", "-n", *map(str, shell_files)], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
ok("shell scripts pass bash -n")
except Exception as exc:
errors.append(f"shell syntax validation failed: {exc}")
# Interface-style standard (0.2.23): every predefined style must be listed in
# the Rust catalog, the settings selector, the JS layout profiles, both palettes
# and the structural token table, so a style can never be half-defined.
skin_catalog_match = re.search(r"pub const INTERFACE_SKINS: &\[&str\] = &\[(.*?)\];", display_prefs_rs, re.S)
skin_catalog = re.findall(r'"([a-z0-9-]+)"', skin_catalog_match.group(1)) if skin_catalog_match else []
require(len(skin_catalog) >= 30 and len(skin_catalog) == len(set(skin_catalog)) and "default" not in skin_catalog, "Rust interface-style catalog is complete and duplicate-free")
require("crate::db::display_preferences::INTERFACE_SKINS" in read("src/config.rs"), "server default interface style is validated against the shared Rust catalog")
skin_select = re.search(r'<select id="pref-interface-skin">(.*?)</select>', index_html, re.S)
skin_options = re.findall(r'<option value="([a-z0-9-]+)"', skin_select.group(1)) if skin_select else []
require(skin_options == ["default", *skin_catalog], "settings interface-style selector lists exactly the Rust catalog in order")
prefs_js = read("src/web/js/20-preferences-auth.js")
profile_block = prefs_js.split("const interfaceStyleProfiles = {", 1)[1].split("\n};", 1)[0] if "const interfaceStyleProfiles = {" in prefs_js else ""
profile_keys = re.findall(r'^ "?([a-z0-9-]+)"?: \{$', profile_block, re.M)
require(sorted(profile_keys) == sorted(skin for skin in skin_catalog if skin != "rustbox"), "every predefined style has a fixed JS layout profile")
palette_css = read("src/web/css/55-roundcube-skins.css") + read("src/web/css/56-interface-styles.css")
missing_palettes = [
f"{skin}/{theme}"
for skin in skin_catalog if skin != "rustbox"
for theme in ("light", "dark")
if not re.search(rf'html\[data-interface-skin="{re.escape(skin)}"\]\[data-theme="{theme}"\]\s*\{{[^}}]*--accent:', palette_css)
]
require(not missing_palettes, "every predefined style ships light and dark palettes" + (f" (missing: {', '.join(missing_palettes)})" if missing_palettes else ""))
token_css = read("src/web/css/57-skin-tokens.css")
missing_tokens = [skin for skin in skin_catalog if f'html[data-interface-skin="{skin}"] {{' not in token_css]
require(not missing_tokens, "every interface style declares structural tokens" + (f" (missing: {', '.join(missing_tokens)})" if missing_tokens else ""))
root_tokens = set(re.findall(r"--(skin-[a-z-]+):", token_css.split("}", 1)[0]))
used_tokens = set(re.findall(r"var\(--(skin-[a-z-]+)\)", read("src/web/css/58-skin-immersive.css")))
declared_tokens = set(re.findall(r"--(skin-[a-z-]+):", token_css))
require(used_tokens <= root_tokens and declared_tokens <= root_tokens, "immersive rules only use structural tokens that have :root defaults")
css_manifest = [line.strip() for line in read("src/web/css/manifest.txt").splitlines() if line.strip()]
require(
"57-skin-tokens.css" in css_manifest and "58-skin-immersive.css" in css_manifest
and css_manifest.index("56-interface-styles.css") < css_manifest.index("57-skin-tokens.css") < css_manifest.index("58-skin-immersive.css") < css_manifest.index("60-responsive.css"),
"style tokens and immersive rules load after the palettes and before responsive overrides",
)
require(
'pub const SKIN_DEPTHS: &[&str] = &["colors", "standard", "immersive"];' in display_prefs_rs
and 'skinDepth: "skin_depth"' in prefs_js and 'id="pref-skin-depth"' in index_html
and "root.dataset.skinDepth" in prefs_js and 'savePreference("skinDepth"' in bindings_js,
"style depth is persisted, exposed in Appearance and applied to the document",
)
for _, dialect in MIGRATION_DIALECTS:
require(all(column in read(f"migrations/{dialect}/schema.sql") and column in read(f"src/queries/{dialect}/queries.rs") for column in ("skin_depth", "skin_gradients")), f"{dialect} fresh schema and display query include skin_depth and skin_gradients")
try:
conn = sqlite3.connect(":memory:")
conn.executescript("CREATE TABLE u_prod__display_preferences (id INTEGER PRIMARY KEY, interface_skin TEXT NOT NULL DEFAULT 'rustbox'); INSERT INTO u_prod__display_preferences(id, interface_skin) VALUES(1, 'nord');")
conn.executescript(read("migrations/sqlite/V053__skin_depth_and_gradients.sql").replace("{{p}}", "u_prod__"))
require(conn.execute("SELECT interface_skin, skin_depth, skin_gradients FROM u_prod__display_preferences WHERE id=1").fetchone() == ("nord", "standard", 0), "SQLite V053 adds skin_depth and skin_gradients with standard/off defaults for existing tenants")
conn.close()
except Exception as exc:
errors.append(f"SQLite V053 migration execution failed: {exc}")
require(
"pub skin_gradients: bool" in display_prefs_rs and 'skinGradients: "skin_gradients"' in prefs_js
and 'id="pref-skin-gradients"' in index_html and "root.dataset.skinGradients" in prefs_js
and 'savePreference("skinGradients"' in bindings_js,
"style gradients have their own persisted Appearance switch",
)
gradient_css = read("src/web/css/59-skin-gradients.css")
require(
"59-skin-gradients.css" in css_manifest
and css_manifest.index("58-skin-immersive.css") < css_manifest.index("59-skin-gradients.css") < css_manifest.index("60-responsive.css"),
"style gradient layer loads after the immersive rules",
)
require(
all(f"var(--grad-{layer})" in gradient_css for layer in ("backdrop", "appbar", "toolbar", "sidebar", "selected", "primary"))
and ':root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .appbar' in gradient_css
and "data-skin-depth" not in gradient_css.split("/* Consumers", 1)[-1].split("{", 1)[0],
"style gradients apply to every predefined style except RustBox at any style depth",
)
# Every gradient in the stylesheets must sit in a rule scoped to the switch.
ungated_gradients = []
for css_name in css_manifest:
css_text = re.sub(r"/\*.*?\*/", "", read(f"src/web/css/{css_name}"), flags=re.S)
for match in re.finditer(r"gradient\(", css_text):
block_start = css_text.rfind("{", 0, match.start())
selector_start = max(css_text.rfind("}", 0, block_start), css_text.rfind(";", 0, block_start))
selector = css_text[selector_start + 1:block_start]
if 'data-skin-gradients="true"' not in selector:
ungated_gradients.append(f"{css_name}: {selector.strip()[:60]}")
require(not ungated_gradients, "every stylesheet gradient is gated by the style-gradients switch" + (f" (ungated: {'; '.join(ungated_gradients[:5])})" if ungated_gradients else ""))
for _, dialect in MIGRATION_DIALECTS:
require(all(column in read(f"migrations/{dialect}/schema.sql") and column in read(f"src/queries/{dialect}/queries.rs") for column in ("theme_light_from", "theme_dark_from", "account_color")), f"{dialect} fresh schema and display query include the theme schedule and account colour")
try:
conn = sqlite3.connect(":memory:")
conn.executescript("CREATE TABLE u_prod__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system'); INSERT INTO u_prod__display_preferences(id) VALUES(1); CREATE TABLE u_prod__calendar_events (id TEXT PRIMARY KEY, reminder_minutes INTEGER); INSERT INTO u_prod__calendar_events(id, reminder_minutes) VALUES('e1', 15); CREATE TABLE u_prod__calendar_settings (id INTEGER PRIMARY KEY); INSERT INTO u_prod__calendar_settings(id) VALUES(1);")
conn.executescript(read("migrations/sqlite/V054__theme_schedule_account_color_and_reminders.sql").replace("{{p}}", "u_prod__"))
require(conn.execute("SELECT theme_light_from, theme_dark_from, account_color FROM u_prod__display_preferences WHERE id=1").fetchone() == ("07:00", "19:00", ""), "SQLite V054 adds the theme schedule and account colour with defaults")
require(conn.execute("SELECT reminder_minutes, reminder_extra_minutes FROM u_prod__calendar_events WHERE id='e1'").fetchone() == (15, None), "SQLite V054 adds an optional second calendar reminder and keeps existing reminders")
require(conn.execute("SELECT default_reminder_minutes, default_extra_reminder_minutes FROM u_prod__calendar_settings WHERE id=1").fetchone() == (None, None), "SQLite V054 adds empty default reminders for new events")
conn.close()
except Exception as exc:
errors.append(f"SQLite V054 migration execution failed: {exc}")
require(
'"schedule"' in display_prefs_rs and 'value="schedule"' in index_html and "function scheduledTheme(" in prefs_js
and 'id="pref-account-color"' in index_html and 'html[data-account-color="true"] .appbar::before' in read("src/web/css/50-settings.css"),
"time-of-day theme and per-account colour are wired end to end",
)
for _, dialect in MIGRATION_DIALECTS:
queries_text = read(f"src/queries/{dialect}/queries.rs")
require(
"reminder_extra_minutes" in read(f"migrations/{dialect}/schema.sql") and "reminder_extra_minutes" in queries_text
and "default_extra_reminder_minutes" in queries_text,
f"{dialect} schema and calendar queries include the second reminder and reminder defaults",
)
calendar_js = read("src/web/js/71-calendar.js")
require(
'id="calendar-reminder-extra"' in index_html and 'id="calendar-default-reminder"' in index_html
and "reminder_extra_minutes: reminderSelectValue(" in calendar_js
and "calendarReminderTrigger(event, minutes)" in read("src/web/js/80-settings-realtime.js"),
"calendar events support a second reminder (e.g. one day before) with defaults for new events",
)
require("function updateContrastWarning(" in prefs_js and 'id="pref-contrast-warning"' in index_html, "custom colours show a live contrast warning")
sieve_js = read("src/web/js/90-sieve.js")
sieve_apply_js = read("src/web/js/91-sieve-apply.js")
filters_rs = read("src/routes/filters.rs")
require(
'.route("/filters/match", post(filters::match_filter_handler))' in read("src/routes/mod.rs")
and "async fn search_uids(" in read("src/imap/client.rs")
and "async fn search_uids(" in read("src/imap/mock.rs")
and all("pub mod db_filter_match" in read(f"src/queries/{d}/queries.rs") for d in ("sqlite", "mysql", "pgsql")),
"filters: POST /filters/match is routed and backed by IMAP UID SEARCH on every client",
)
require(
"fn bounds(" in filters_rs and "fn resolve_matches(" in filters_rs and "uncertain" in filters_rs and "#[cfg(test)]" in filters_rs,
"filters: apply-now matching uses upper/lower IMAP bounds, decides the gap from the cache and reports uncertain messages",
)
require(
"91-sieve-apply.js" in read("src/web/js/manifest.txt")
and 'id="sieve-apply-check"' in index_html and 'id="sieve-apply-run"' in index_html
and "function runParsedSieveRuleNow(" in sieve_apply_js and "function alwaysMoveFromSender(" in sieve_apply_js
and "function ensureSieveTargetFolders(" in sieve_apply_js and 'id="sieve-templates"' in index_html,
"filters: apply now, per-rule run now, templates, missing-folder creation and 'always move from this sender' are wired",
)
require(
'id="sieve-builder-exceptions"' in index_html and "function splitSieveExceptions(" in sieve_js
and all(f'"{kind}"' in sieve_js for kind in ("to-only", "cc-only", "size-over", "size-under", "has-attachment", "priority-high"))
and '"label"' in sieve_js and '"trash"' in sieve_js,
"filters: exceptions, new conditions (To/Cc only, size, attachment, priority) and label/trash actions round-trip through the editor",
)
mysql_text_literal_defaults = re.findall(r"^\s*(\w+)\s+TEXT(?:\s+NOT NULL)?\s+DEFAULT\s+'", read("migrations/mysql/schema.sql") + "\n" + "\n".join(read(f"migrations/mysql/{name}") for _, name in MIGRATIONS["mysql"]), re.M)
require(not mysql_text_literal_defaults, "MySQL TEXT columns use expression defaults" + (f" (literal: {', '.join(mysql_text_literal_defaults)})" if mysql_text_literal_defaults else ""))
# TODO/release honesty: live integrations remain explicitly pending.
todo = read("TODO.md")
require("[ ] SILO Compose integration validation" in todo, "TODO keeps live SILO validation pending")
require("[ ] MinIO integration validation" in todo, "TODO keeps live MinIO validation pending")
require("[ ] AWS S3 integration validation" in todo, "TODO keeps live AWS S3 validation pending")
require("[ ] Ceph RGW compatibility validation" in todo, "TODO keeps live Ceph RGW validation pending")
require("[ ] S3-compatible storage works for draft attachments (live SILO/MinIO/AWS validation pending)" in todo, "acceptance does not overclaim live S3 validation")
print(f"static checks passed: {len(checks)}")
for message in checks:
print(f"PASS {message}")
if errors:
print(f"static checks failed: {len(errors)}", file=sys.stderr)
for message in errors:
print(f"FAIL {message}", file=sys.stderr)
raise SystemExit(1)