From 698e3c4a1ba456664e3dab4bbc0dbbc2bcc7a129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 31 Oct 2026 00:37:16 +0100 Subject: [PATCH] v0.1.1 --- CHANGELOG.md | 14 + Cargo.lock | 2 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 28 +- RELEASE-MANIFEST.txt | 113 +- RELEASE-VALIDATION.txt | 28 + config.dev.toml | 7 +- config.example.toml | 9 +- docs/IMPLEMENTATION_STATUS.md | 4 +- docs/UPSTREAM.md | 34 +- migrations/README.md | 52 + migrations/mysql/V047__sessions.sql | 23 - migrations/mysql/schema.sql | 424 +++ migrations/pgsql/V047__sessions.sql | 23 - migrations/pgsql/schema.sql | 425 +++ migrations/sqlite/V001__initial_schema.sql | 6 - .../sqlite/V002__folders_and_messages.sql | 41 - migrations/sqlite/V003__add_date_epoch.sql | 6 - .../V004__folder_messages_updated_at.sql | 3 - .../sqlite/V005__drafts_and_attachments.sql | 24 - .../V006__cache_attachments_and_headers.sql | 3 - migrations/sqlite/V007__contacts.sql | 18 - migrations/sqlite/V008__identities.sql | 9 - .../sqlite/V009__notification_preferences.sql | 9 - migrations/sqlite/V010__contact_groups.sql | 16 - migrations/sqlite/V011__message_reaction.sql | 1 - migrations/sqlite/V012__tags.sql | 20 - .../sqlite/V013__display_preferences.sql | 7 - migrations/sqlite/V014__compose_format.sql | 1 - migrations/sqlite/V015__deep_index.sql | 1 - migrations/sqlite/V016__calendar.sql | 51 - migrations/sqlite/V017__add_email_theme.sql | 4 - migrations/sqlite/V018__animation_mode.sql | 2 - migrations/sqlite/V019__known_addresses.sql | 34 - .../V020__appearance_personalization.sql | 17 - migrations/sqlite/V021__special_folders.sql | 5 - migrations/sqlite/V022__identity_options.sql | 8 - migrations/sqlite/V023__saved_searches.sql | 9 - migrations/sqlite/V024__notification_dnd.sql | 3 - .../sqlite/V025__custom_theme_colors.sql | 3 - migrations/sqlite/V026__mail_preferences.sql | 14 - migrations/sqlite/V027__contact_profile.sql | 13 - .../sqlite/V028__contact_multi_values.sql | 3 - .../sqlite/V029__draft_send_options.sql | 2 - .../sqlite/V030__folder_list_preferences.sql | 7 - .../sqlite/V031__compose_preferences.sql | 4 - .../sqlite/V032__advanced_appearance.sql | 15 - .../sqlite/V033__draft_compose_format.sql | 1 - migrations/sqlite/V034__imap_keyword_sync.sql | 4 - .../V035__compose_behavior_preferences.sql | 5 - .../sqlite/V036__remote_draft_tracking.sql | 3 - migrations/sqlite/V037__openpgp_keyring.sql | 24 - migrations/sqlite/V038__identity_openpgp.sql | 4 - .../sqlite/V039__draft_openpgp_options.sql | 2 - .../sqlite/V040__draft_openpgp_attach_key.sql | 1 - .../V041__draft_openpgp_recipient_keys.sql | 1 - migrations/sqlite/V042__trusted_senders.sql | 4 - .../sqlite/V043__response_templates.sql | 7 - migrations/sqlite/V044__interface_skin.sql | 1 - .../sqlite/V045__response_template_format.sql | 1 - .../sqlite/V046__preset_scale_unlock.sql | 1 - migrations/sqlite/V047__sessions.sql | 23 - migrations/sqlite/schema.sql | 344 +++ packaging/rustbox.toml | 29 +- qar.txt | 9 - scripts/finish-upstream.sh | 13 +- scripts/oxi-port-commit.sh | 6 +- scripts/oxi-status.sh | 7 +- scripts/setup-upstream.sh | 2 +- scripts/sync-upstream.sh | 7 +- src/auth/user_data.rs | 24 +- src/config.rs | 24 +- src/db/calendar.rs | 105 +- src/db/contact_groups.rs | 29 +- src/db/contacts.rs | 76 +- src/db/display_preferences.rs | 150 +- src/db/drafts.rs | 78 +- src/db/folder_preferences.rs | 51 +- src/db/folders.rs | 170 +- src/db/header_sync.rs | 21 +- src/db/identities.rs | 96 +- src/db/mail_preferences.rs | 47 +- src/db/messages.rs | 150 +- src/db/notification_preferences.rs | 73 +- src/db/pgp.rs | 28 +- src/db/pool.rs | 2651 +++++++++++++++-- src/db/response_templates.rs | 43 +- src/db/saved_searches.rs | 29 +- src/db/special_folders.rs | 36 +- src/db/tags.rs | 69 +- src/db/trusted_senders.rs | 18 +- src/main.rs | 3 + src/pgp/discovery.rs | 2 +- src/queries/mysql/queries.rs | 141 +- src/queries/pgsql/queries.rs | 129 +- src/queries/sqlite/queries.rs | 26 +- src/realtime/sync.rs | 27 +- src/routes/calendar.rs | 2 +- src/routes/contacts.rs | 18 +- src/routes/diagnostics.rs | 20 +- src/routes/drafts.rs | 2 +- src/routes/folder_mgmt.rs | 20 +- src/routes/identities.rs | 2 +- src/routes/messages/mod.rs | 6 +- src/routes/pgp.rs | 4 +- src/routes/send.rs | 6 +- src/routes/special_folders.rs | 4 +- src/routes/tags.rs | 2 +- src/routes/unsubscribe.rs | 2 +- src/session_store/database.rs | 42 +- src/sieve/error.rs | 2 +- tests/roundcube_skin_contract.py | 157 - tests/test_fresh_sqlite_migrations.py | 32 - tests/test_message_projection_010.py | 74 - tests/test_query_layout_010.py | 91 - tests/test_release_005_contract.py | 52 - tests/test_release_006_contract.py | 71 - tests/test_release_007_contract.py | 98 - tests/test_release_010_contract.py | 123 - tests/test_session_storage_runtime.py | 61 - tests/test_warning_hygiene.py | 35 - tests/test_warning_hygiene_010.py | 32 - tests/theme_fixture.html | 12 - 124 files changed, 4593 insertions(+), 2721 deletions(-) create mode 100644 RELEASE-VALIDATION.txt create mode 100644 migrations/README.md delete mode 100644 migrations/mysql/V047__sessions.sql create mode 100644 migrations/mysql/schema.sql delete mode 100644 migrations/pgsql/V047__sessions.sql create mode 100644 migrations/pgsql/schema.sql delete mode 100644 migrations/sqlite/V001__initial_schema.sql delete mode 100644 migrations/sqlite/V002__folders_and_messages.sql delete mode 100644 migrations/sqlite/V003__add_date_epoch.sql delete mode 100644 migrations/sqlite/V004__folder_messages_updated_at.sql delete mode 100644 migrations/sqlite/V005__drafts_and_attachments.sql delete mode 100644 migrations/sqlite/V006__cache_attachments_and_headers.sql delete mode 100644 migrations/sqlite/V007__contacts.sql delete mode 100644 migrations/sqlite/V008__identities.sql delete mode 100644 migrations/sqlite/V009__notification_preferences.sql delete mode 100644 migrations/sqlite/V010__contact_groups.sql delete mode 100644 migrations/sqlite/V011__message_reaction.sql delete mode 100644 migrations/sqlite/V012__tags.sql delete mode 100644 migrations/sqlite/V013__display_preferences.sql delete mode 100644 migrations/sqlite/V014__compose_format.sql delete mode 100644 migrations/sqlite/V015__deep_index.sql delete mode 100644 migrations/sqlite/V016__calendar.sql delete mode 100644 migrations/sqlite/V017__add_email_theme.sql delete mode 100644 migrations/sqlite/V018__animation_mode.sql delete mode 100644 migrations/sqlite/V019__known_addresses.sql delete mode 100644 migrations/sqlite/V020__appearance_personalization.sql delete mode 100644 migrations/sqlite/V021__special_folders.sql delete mode 100644 migrations/sqlite/V022__identity_options.sql delete mode 100644 migrations/sqlite/V023__saved_searches.sql delete mode 100644 migrations/sqlite/V024__notification_dnd.sql delete mode 100644 migrations/sqlite/V025__custom_theme_colors.sql delete mode 100644 migrations/sqlite/V026__mail_preferences.sql delete mode 100644 migrations/sqlite/V027__contact_profile.sql delete mode 100644 migrations/sqlite/V028__contact_multi_values.sql delete mode 100644 migrations/sqlite/V029__draft_send_options.sql delete mode 100644 migrations/sqlite/V030__folder_list_preferences.sql delete mode 100644 migrations/sqlite/V031__compose_preferences.sql delete mode 100644 migrations/sqlite/V032__advanced_appearance.sql delete mode 100644 migrations/sqlite/V033__draft_compose_format.sql delete mode 100644 migrations/sqlite/V034__imap_keyword_sync.sql delete mode 100644 migrations/sqlite/V035__compose_behavior_preferences.sql delete mode 100644 migrations/sqlite/V036__remote_draft_tracking.sql delete mode 100644 migrations/sqlite/V037__openpgp_keyring.sql delete mode 100644 migrations/sqlite/V038__identity_openpgp.sql delete mode 100644 migrations/sqlite/V039__draft_openpgp_options.sql delete mode 100644 migrations/sqlite/V040__draft_openpgp_attach_key.sql delete mode 100644 migrations/sqlite/V041__draft_openpgp_recipient_keys.sql delete mode 100644 migrations/sqlite/V042__trusted_senders.sql delete mode 100644 migrations/sqlite/V043__response_templates.sql delete mode 100644 migrations/sqlite/V044__interface_skin.sql delete mode 100644 migrations/sqlite/V045__response_template_format.sql delete mode 100644 migrations/sqlite/V046__preset_scale_unlock.sql delete mode 100644 migrations/sqlite/V047__sessions.sql create mode 100644 migrations/sqlite/schema.sql delete mode 100644 qar.txt delete mode 100644 tests/roundcube_skin_contract.py delete mode 100644 tests/test_fresh_sqlite_migrations.py delete mode 100644 tests/test_message_projection_010.py delete mode 100644 tests/test_query_layout_010.py delete mode 100644 tests/test_release_005_contract.py delete mode 100644 tests/test_release_006_contract.py delete mode 100644 tests/test_release_007_contract.py delete mode 100644 tests/test_release_010_contract.py delete mode 100644 tests/test_session_storage_runtime.py delete mode 100644 tests/test_warning_hygiene.py delete mode 100644 tests/test_warning_hygiene_010.py delete mode 100644 tests/theme_fixture.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b31593d..b9d93f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # RustBox changelog +### 0.1.1 +- Fixed the multi-backend compatibility layer compile blockers reported by the Rust compiler: empty `[]` parameter inference no longer overlaps the generic array implementation, legacy SQLite probing imports `rusqlite::OptionalExtension` without colliding with RustBox's own `OptionalExtension`, and MySQL/PostgreSQL transaction batch execution now uses the typed SQLx transaction helpers instead of the lifetime-problematic `raw_sql` executor path. +- Unified the storage model: `database_backend = sqlite | mysql | pgsql` now selects the database engine for the whole application instead of only the SQL session store; `session_backend = database | redis` either keeps sessions in that same database or moves them to Redis/Redis Sentinel. +- Added complete SQLite, MySQL and PostgreSQL baselines under `migrations/{sqlite,mysql,pgsql}/schema.sql`. Each schema contains global sessions plus the full account-data schema at baseline version 46; the historical SQLite V001-V046 files are squashed out of the release tree and V047 is reserved for the next schema change across all engines. +- Preserved per-account data isolation in a shared physical database with deterministic collision-checked table prefixes, while keeping the session table global. +- Added automatic first-use import of legacy 0.1.0 `data_dir//db.sqlite` databases at `PRAGMA user_version = 46` into the configured backend. The source database is renamed to `db.sqlite.pre-0.1.1.bak` only after a successful import; older schemas are rejected with an explicit upgrade requirement. +- Added a backend-neutral database compatibility layer used by the existing Rust data modules and switched application query selection to the SQLite/MySQL/PostgreSQL catalog matching the configured backend. +- Corrected MySQL/PostgreSQL query dialects and parameter handling, including numbered/reused parameters, upsert syntax, timestamp expressions, JSON address lookup and generated-ID handling. +- Fixed transaction scope for server databases so cache/message synchronization, header invalidation and other grouped writes execute on the actual MySQL/PostgreSQL transaction connection rather than accidentally falling back to the pool. +- Removed separate `session.sql` files: sessions are part of each engine's complete `schema.sql`; Redis remains schema-less. +- Removed stale Python release-contract tests and the unused HTML fixture; retained the runtime JSON fixture used by Rust tests and added native tests for tenant scoping, placeholder adaptation and legacy SQLite import. +- Made Oxi synchronization treat upstream `backend/migrations/` and `backend/tests/` as RustBox-owned review-only areas so schemas and native tests are never overwritten automatically. +- Removed the accidental runtime log `qar.txt` from the source tree and refreshed release metadata for 0.1.1. + ### 0.1.0 - Cleaned the final release gate for Rust 1.99 Clippy with `-D warnings`: refactored persistent session creation into a typed input struct and fixed all reported collapsible conditionals, redundant closures, needless borrows/question marks, test layout and collection checks without disabling Clippy globally. - Fixed fresh user-database provisioning so only SQLite user migrations `V001`-`V046` are applied; session migration `V047` is now exclusively owned by the configured session-store backend and MySQL SQL can no longer be executed against a user SQLite database. diff --git a/Cargo.lock b/Cargo.lock index e43a6c5..7a20750 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3076,7 +3076,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.1.0" +version = "0.1.1" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index e3a1e9c..1de3658 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.88" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index f9dda57..4100e58 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.1.0 is derived from the Oxi webmail project: +RustBox 0.1.1 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index 23de161..78b10e4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.1.0 +# RustBox 0.1.1 RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. @@ -70,27 +70,33 @@ If the mail host settings are omitted, RustBox uses `localhost` with IMAP `993` Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative. -## Persistent sessions and cluster traffic +## Database, sessions and cluster traffic -RustBox 0.1.0 persists login sessions by default in SQLite at `data_dir/rustbox.sqlite`. The session contains encrypted IMAP/SMTP credentials so it can survive a process restart; the encryption key is stored as `data_dir/session.key` with restrictive permissions for the default single-node SQLite setup. +RustBox 0.1.1 uses one SQL backend for the whole application. `database_backend = "sqlite" | "mysql" | "pgsql"` selects where account data is stored and, by default, where persistent login sessions are stored. SQLite defaults to `data_dir/rustbox.sqlite`; MySQL and PostgreSQL require `database_url`. -RustBox uses `rusqlite` for SQLite and `sqlx` only for MySQL/PostgreSQL, avoiding duplicate native SQLite linkage in the Cargo dependency graph. +Account data remains isolated per account inside the selected database. Existing 0.1.0 per-user SQLite databases at `data_dir//db.sqlite` are imported on first access when they are at schema version 46; the source file is retained as `db.sqlite.pre-0.1.1.bak` after a successful import. -The session database backend can be `sqlite`, `mysql` or `pgsql`. For multi-node deployments use MySQL/PostgreSQL or Redis, set `cluster_mode = true`, and provide the same base64-encoded 32-byte `session_encryption_key` on every node. Redis is configured independently from the database and can use either a direct URL or Redis Sentinel discovery. +`session_backend = "database"` stores sessions in the selected application database. `session_backend = "redis"` uses Redis instead; Redis Sentinel is supported through the Sentinel settings. For a multi-node deployment the application database itself must be shared MySQL/PostgreSQL, even when Redis stores sessions. Provide the same base64-encoded 32-byte `session_encryption_key` on every node that shares sessions. ```toml -# Default: SQLite under data_dir. +# Default: the whole application uses SQLite under data_dir. database_backend = "sqlite" # database_url = "sqlite:///var/lib/rustbox/rustbox.sqlite?mode=rwc" session_backend = "database" -# Shared SQL example: +# Full PostgreSQL backend: # database_backend = "pgsql" # database_url = "postgres://rustbox:secret@db/rustbox" -# cluster_mode = true +# session_backend = "database" # session_encryption_key = "" -# Redis example; the application database remains configured separately above. +# Full MySQL backend (MySQL 8.0.13+): +# database_backend = "mysql" +# database_url = "mysql://rustbox:secret@db/rustbox" +# session_backend = "database" +# session_encryption_key = "" + +# Optional Redis session persistence; application data still uses database_backend. # session_backend = "redis" # redis_url = "redis://redis:6379/" @@ -101,7 +107,7 @@ session_backend = "database" # redis_password = "secret" ``` -All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. The migration tree is flat per dialect: `migrations/sqlite/`, `migrations/mysql/`, and `migrations/pgsql/`. SQLite user-schema migrations are `V001`-`V046`, and the session schema is `V047__sessions.sql` for each SQL dialect. +All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. Each engine has one complete baseline in `migrations/{sqlite,mysql,pgsql}/schema.sql`, including the session table. The squashed baseline is schema version 46 and the next schema migration number is V047 for all engines. Redis requires no SQL migration. See `migrations/README.md`. ## Development @@ -170,7 +176,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.1.0 release" +git commit -m "RustBox 0.1.1 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index a33ceae..af95220 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,89 +1,30 @@ -RustBox 0.1.0 source release +RustBox 0.1.1 release manifest -Runtime/source model: -- one root Cargo package -- src/ Rust application code -- src/web/ RustBox-owned embedded browser UI -- migrations/ dialect-separated SQLite/MySQL/PostgreSQL migrations -- no TypeScript/React/Next.js/Node.js/Bun build -- ManageSieve TLS/STARTTLS/explicit insecure transport option -- native systemd packaging +Storage model: +- database_backend = sqlite | mysql | pgsql selects the full application database +- session_backend = database stores sessions in the same database +- session_backend = redis uses Redis or Redis Sentinel for sessions + +Database baselines: +- migrations/sqlite/schema.sql: complete SQLite schema, baseline version 46 +- migrations/mysql/schema.sql: complete MySQL schema, baseline version 46 +- migrations/pgsql/schema.sql: complete PostgreSQL schema, baseline version 46 +- each schema includes global sessions and isolated account-data tables +- historical SQLite V001-V046 files are intentionally squashed out of this release +- next migration number for every SQL engine: V047 + +Compatibility: +- legacy RustBox 0.1.0 per-user SQLite databases at user_version 46 are imported on first access +- imported source DBs are retained as db.sqlite.pre-0.1.1.bak +- older legacy schemas must first be upgraded with RustBox 0.1.0 + +Tests/source cleanup: +- tests/fixtures/account-session-expired.json is retained because Rust tests include it +- stale tests/*.py and tests/theme_fixture.html are not part of the release tree Oxi tracking: -- UPSTREAM_BASE stores consumed Oxi revision -- scripts/setup-upstream.sh configures upstream-oxi -- scripts/oxi-status.sh reports upstream changes -- scripts/sync-upstream.sh maps the cumulative Oxi backend delta into flat RustBox paths -- scripts/finish-upstream.sh advances UPSTREAM_BASE only after explicit upstream review/build validation -- scripts/oxi-port-commit.sh maps a selected Oxi backend commit without advancing the baseline -- Oxi frontend changes are reviewed and manually ported to src/web/ -- docs/UI.md documents the browser UI/build conventions - -Current 0.1.0 release additions: -- persistent restart-safe sessions; SQLite under data_dir is the zero-config default -- optional MySQL/PostgreSQL session databases -- optional Redis sessions with direct URL or Redis Sentinel master discovery/failover lookup -- shared-session validation for load-balanced cluster traffic and cross-node revocation -- AES-256-GCM encryption for persisted IMAP/SMTP session credentials -- all executable Rust SQL centralized under src/queries/{sqlite,mysql,pgsql}/queries.rs, including sessions -- cached-message query projections are kept in a strict 16-column contract matching Rust row decoding -- flat migrations under migrations/{sqlite,mysql,pgsql}; SQLite user schema uses V001-V046 and sessions use V047 per SQL dialect -- user provisioning uses only the canonical SQLite V001-V046 runner; session V047 is selected separately by the configured session backend and Refinery is no longer used -- allowed downloads explicitly from the sandboxed HTML message reader while retaining popup isolation -- added GitHub-, GitLab-, Discord-, Slack-, Reddit- and Notion-inspired interface styles in light and dark variants -- added release contracts that require every predefined interface style to be visible in the selector, backed by CSS and accepted by backend/config validation -- attachment left-click defaults to safe in-app preview for supported types -- attachment right-click exposes explicit Preview and Download actions -- oversized preview attempts offer direct download instead of ending with a warning -- Polish composer, reader toolbar and per-message context-menu translation pass -- grouped reader message details menu for headers/source and transfer menu for move/copy -- compact composer format selector moved to the lower-right opposite Send/Cancel -- mobile layout reliability pass for settings, source/header/calendar dialogs and narrow toolbars -- fixed Sieve visual-editor checkbox alignment and mobile single-column rule controls -- tap/keyboard expansion for full sender/recipient address details in the reader -- persistent IMAP quota progress above the sidebar IMAP/version footer -- global TOML `default_theme` for light/dark mode and `default_interface_skin` for login + account-wide default interface styling -- Gmail-, Proton-, Zoho- and Outlook-inspired interface profiles plus Minimal -- expanded Polish localization, including scoped dialog/settings text and common attributes -- JavaScript source split into functional `src/web/js/` modules and assembled by Cargo build.rs -- Interface style selector includes RustBox custom, Roundcube Classic/Larry/Elastic, Clean, Graphite, High contrast, Gmail/Proton/Zoho/Outlook/Fastmail/Apple Mail/Yahoo/HEY/GitHub/GitLab/Discord/Slack/Reddit/Notion-inspired profiles, Nord and Minimal -- legacy Compact/Balanced/Comfortable/Reading appearance cards removed -- persisted special folders and mailbox behavior preferences -- bulk IMAP actions, copy/move, folder mark-all-read/empty, message-to-folder drag/drop -- HTML/plain/Markdown composer modes, CID inline images and expanded identity/signature rules -- advanced/saved search, tags, contact groups, visual Sieve/vacation builder -- IMAP IDLE/WebSocket refresh, desktop notifications and DND -- IMAP capabilities diagnostics and multi-account browser session UI -- RFC822 .eml download/import, List-Unsubscribe handling and RFC 8058 one-click HTTPS unsubscribe -- expanded contacts with multiple e-mails/phones/addresses, vCard profile fields, duplicate merge and validated PNG/JPEG/WebP photos -- calendar Month/Week/Day/Agenda views, richer event metadata and ICS import/export -- search-index status/rebuild, IMAP reconnect diagnostics and live cross-browser session revoke UI -- 46 contiguous SQLite user migrations wired sequentially as V001-V046; V047 is reserved for the separate session store in each SQL dialect -- safe Undo for resolvable IMAP MOVE actions and select-all across complete cached search results -- exact-token thread matching and expanded keyboard selection/navigation shortcuts -- in-app attachment preview, clipboard CID image paste and upload progress -- separate 10 MiB per-file attachment limit and 50 MiB final RFC822/MIME message limit with pre-SMTP exact byte validation and composer size estimate -- recoverable drafts with browser emergency snapshots and throttled IMAP Drafts snapshots -- outgoing priority and MDN/read-receipt draft/send options with pre-SMTP mailbox validation -- expanded Sieve visual rules: nested three-condition grouping, two actions, managed reordering, backup/restore, active overwrite guard and optional date-scheduled vacation -- spam destination default plus per-account override, pre-pagination flagged-first sorting with per-folder overrides, and subtle flagged-row markers -- direct ManageSieve quick-filter wizard with validation/save/activation and advanced-editor handoff -- unread browser/app badge updates and a public local-storage readiness endpoint -- inline image editing with display sizing, alignment, crop/cut preview and CID cleanup -- dedicated Composing settings for format, autosave interval, default priority, MDN and attachment reminder -- corrected Settings header/content grid for short panels and scroll-safe navigation - -Validation note: -- 56 Python release/runtime/query-layout contract tests passed, including fresh SQLite user migrations, separate SQLite session migration, session restart persistence, query-catalog parity and warning-hygiene contracts. -- The assembled browser JavaScript bundle passed `node --check`; shell scripts passed `bash -n`; JSON/TOML/Python sources and Cargo.lock parsed successfully. -- `scripts/build-release.sh` now enforces `cargo fmt --check`, `cargo test --locked`, `cargo clippy --all-targets -- -D warnings`, and `cargo build --release --locked`. -- A full Rust gate could not be executed in this environment because cargo/rustc are unavailable; the release script and CI must pass on the toolchain host before deployment. - -0.1.0 additions: -- mobile reader action and attachment fixes -- WebSocket heartbeat, reconnect backoff, watchdog, resync, dedupe and fallback polling -- reliable live new-message refresh and unread reconciliation -- notification click-through to the newest message when UID is available -- full-screen message reader with context-menu entry, Escape, history and deep links -- composer-only upload progress with duplicate and cancellation safeguards -- source-code comment removal and regression contracts +- UPSTREAM_BASE stores the consumed Oxi revision +- backend source changes are mapped into the flat RustBox tree +- upstream backend/migrations and backend/tests are review-only and never auto-imported +- frontend changes are reviewed and manually ported to src/web/ +- scripts/finish-upstream.sh requires explicit acknowledgement for changed RustBox-owned upstream areas before advancing UPSTREAM_BASE diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt new file mode 100644 index 0000000..10ac934 --- /dev/null +++ b/RELEASE-VALIDATION.txt @@ -0,0 +1,28 @@ +RustBox 0.1.1 - release validation + +Completed in this environment: +- compiler-log remediation: all 47 reported compile errors map to and are covered by three root fixes (41 E0283 empty-parameter inference errors, 2 E0599 rusqlite OptionalExtension errors, 4 SQLx transaction Executor lifetime errors) +- version consistency: Cargo.toml / Cargo.lock / CHANGELOG.md / release metadata = 0.1.1 +- migration layout: exactly one schema.sql for sqlite, mysql and pgsql; no session.sql or V001-V046 files +- query catalog parity: 27 modules / 197 public query constants present in sqlite, mysql and pgsql catalogs +- include_str! validation: all referenced files exist +- logical schema parity: sqlite/mysql/pgsql define the same 28 logical tables and column sets +- SQLite execution test: global schema + account tenant schema execute successfully with foreign keys enabled +- SQLite session smoke test: account and session rows insert successfully +- TOML parse: Cargo.toml, config.example.toml and config.dev.toml +- shell syntax: dev.sh and scripts/*.sh pass bash -n +- browser JavaScript: manifest resolves 13 files; concatenated production-order bundle passes node --check +- cleanup: no stale session.sql, V001-V046, migrations/user or migrations/session references outside historical changelog/manifest notes +- tests cleanup: only tests/fixtures/account-session-expired.json remains in tests/ + +Not executable in this environment: +- cargo fmt/test/clippy/build: Rust/Cargo toolchain is not installed and outbound package/toolchain download is unavailable +- live MySQL/PostgreSQL integration tests: database servers/clients are not installed + +The repository contains native Rust tests for tenant scoping, placeholder adaptation and legacy SQLite import; run scripts/build-release.sh on a Rust 1.88+ build host before publishing a production binary. + +Hotfix validation (release-gate lint failures): +- removed unusable null_db_value overrides for unsized str/[u8] +- removed redundant row_to_cached_message closure +- collapsed MySQL reordered-params conditional +- cargo/rustc unavailable in packaging environment; run scripts/build-release.sh on Rust host diff --git a/config.dev.toml b/config.dev.toml index 0d84258..e77e937 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -11,13 +11,14 @@ default_interface_skin = "rustbox" data_dir = ".dev/data" session_timeout_hours = 24 -# Persistent sessions. With no extra configuration, sessions use SQLite at -# data_dir/rustbox.sqlite and survive process restarts. +# Application database. The selected engine stores all RustBox account data. +# With session_backend="database", it stores sessions as well. database_backend = "sqlite" # sqlite | mysql | pgsql +# SQLite defaults to data_dir/rustbox.sqlite. MySQL/PostgreSQL require database_url. # database_url = "postgres://rustbox:secret@db.example.com/rustbox" session_backend = "database" # database | redis cluster_mode = false -# For shared DB/Redis or cluster mode provide the same 32-byte key (base64) on all nodes. +# For shared database sessions, Redis sessions, or cluster mode provide the same 32-byte key (base64) on all nodes. # session_encryption_key = "..." # Direct Redis session backend: # redis_url = "redis://redis.example.com:6379/" diff --git a/config.example.toml b/config.example.toml index 66c5450..355f755 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.1.0 +# RustBox 0.1.1 host = "127.0.0.1" port = 3001 environment = "production" @@ -12,13 +12,14 @@ data_dir = "/var/lib/rustbox" log_dir = "/var/log/rustbox" session_timeout_hours = 24 -# Persistent sessions. With no extra configuration, sessions use SQLite at -# data_dir/rustbox.sqlite and survive process restarts. +# Application database. The selected engine stores all RustBox account data. +# With session_backend="database", it stores sessions as well. database_backend = "sqlite" # sqlite | mysql | pgsql +# SQLite defaults to data_dir/rustbox.sqlite. MySQL/PostgreSQL require database_url. # database_url = "postgres://rustbox:secret@db.example.com/rustbox" session_backend = "database" # database | redis cluster_mode = false -# For shared DB/Redis or cluster mode provide the same 32-byte key (base64) on all nodes. +# For shared database sessions, Redis sessions, or cluster mode provide the same 32-byte key (base64) on all nodes. # session_encryption_key = "..." # Direct Redis session backend: # redis_url = "redis://redis.example.com:6379/" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 35fd8d4..71a5f8e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -84,13 +84,13 @@ This file tracks the internal `0.0.4` baseline. The user-approved `docs/RUSTBOX_ - Account switch/remove/add workflow for multiple authenticated accounts in one browser session. - Active-session list across browsers while the RustBox process is running, with creation/last-active timestamps, friendly browser/device labels, full User-Agent hover detail, individual revoke and revoke-all-others. - Diagnostics panel exposing IMAP capabilities (including persistent custom-keyword support), authenticated SMTP connectivity, ManageSieve status, browser WebSocket state, local synchronization-cache timestamps/counts and live IMAP IDLE task/subscriber counts without returning server host names or credentials. Public liveness (`/api/health`) and local-storage readiness (`/api/ready`) endpoints are available for service supervision. -- Search-index diagnostics expose cached/indexed counts, with a rebuild operation from the local SQLite cache. +- Search-index diagnostics expose cached/indexed counts, with a rebuild operation from the configured account-data backend. - IMAP reconnect control drops reusable request-path sessions and verifies a fresh authenticated connection. - Graceful SIGTERM/Ctrl+C shutdown stops accepting new requests and aborts dedicated IMAP IDLE tasks before process exit. ### Personalization -- Persistent per-user display preferences in SQLite. +- Persistent per-user display preferences in the configured application database. - Light/dark/system theme. - Presets: sage, blue, teal, cyan, indigo, slate, graphite, amber, orange, violet, rose, red and custom. - Custom accent, background, panel and text colors. diff --git a/docs/UPSTREAM.md b/docs/UPSTREAM.md index 01a6002..63da0a2 100644 --- a/docs/UPSTREAM.md +++ b/docs/UPSTREAM.md @@ -6,19 +6,24 @@ RustBox uses a flat Cargo layout, while Oxi uses a `backend/` subtree. The sync ## Path mapping -Examples: +Automatically mapped examples: ```text Oxi backend/Cargo.toml -> RustBox Cargo.toml Oxi backend/Cargo.lock -> RustBox Cargo.lock Oxi backend/rust-toolchain.toml -> RustBox rust-toolchain.toml Oxi backend/src/... -> RustBox src/... -Oxi backend/migrations/... -> RustBox migrations/... -Oxi backend/tests/... -> RustBox tests/... Oxi backend/examples/... -> RustBox examples/... Oxi backend/docs/... -> RustBox docs/... ``` +Review-only boundaries, never mapped automatically: + +```text +Oxi backend/migrations/... -> review against RustBox migrations/... +Oxi backend/tests/... -> review/port as native Rust tests when useful +``` + RustBox-owned `src/web/` has no automatic mapping from the Oxi frontend. ## Configure the upstream @@ -28,7 +33,7 @@ A release archive does not contain `.git`. Initialize the RustBox repository onc ```bash git init git add . -git commit -m "RustBox 0.0.1 baseline" +git commit -m "RustBox 0.1.1 baseline" ./scripts/setup-upstream.sh ``` @@ -55,10 +60,11 @@ or inspect a specific Oxi ref: The report separates: -1. Oxi `backend/` changes that can be mapped into RustBox; -2. Oxi `frontend/` changes that require a manual UI port; -3. other Oxi root-level changes that require manual review; -4. upstream commits since `UPSTREAM_BASE`. +1. Oxi backend changes that can be mapped automatically; +2. Oxi backend migrations/tests that require manual review; +3. Oxi frontend changes that require a manual UI port; +4. other Oxi root-level changes that require manual review; +5. upstream commits since `UPSTREAM_BASE`. ## Advance the consumed Oxi backend state @@ -72,10 +78,10 @@ The operation: 2. fetches `upstream-oxi`; 3. verifies that the target descends from the recorded `UPSTREAM_BASE`; 4. creates a `sync/oxi-*` review branch; -5. generates the Oxi backend delta from `UPSTREAM_BASE` to the target; +5. generates the Oxi backend delta from `UPSTREAM_BASE` to the target, excluding `backend/migrations/` and `backend/tests/`; 6. rewrites only patch metadata so `backend/` becomes ``; 7. applies the mapped delta with Git three-way merge support; -8. does not import Oxi frontend sources; +8. does not import Oxi frontend sources, migrations, or tests; 9. records the target as a pending sync inside Git metadata; 10. deliberately leaves `UPSTREAM_BASE` unchanged until the maintainer finishes review. @@ -87,12 +93,14 @@ After resolving backend conflicts and porting any relevant UI behavior, finish t ./scripts/finish-upstream.sh ``` -If Oxi changed its frontend or root-level files, the finish step refuses to advance the baseline until you explicitly acknowledge those reviews: +If Oxi changed its frontend, root-level files, or the RustBox-owned upstream migration/test areas, the finish step refuses to advance the baseline until those reviews are explicitly acknowledged: ```bash -./scripts/finish-upstream.sh --ui-reviewed --root-reviewed +./scripts/finish-upstream.sh --ui-reviewed --root-reviewed --rustbox-owned-reviewed ``` +`--rustbox-owned-reviewed` confirms that changes under Oxi `backend/migrations/` and `backend/tests/` were inspected and any relevant behavior was manually ported. Those files are never copied into RustBox automatically. + The finish step runs the RustBox release build gate and only then stages the new `UPSTREAM_BASE`. Review `git status` and commit the sync. `--skip-build` exists for exceptional local workflows, but CI should still run the full gate before merge. If the sync is rejected, discard the review branch; the main branch and its `UPSTREAM_BASE` remain unchanged. @@ -123,6 +131,8 @@ Oxi frontend source is never copied into RustBox. For a new Oxi feature that inc These areas require explicit review and should not be replaced blindly by upstream changes: +- `migrations/` (RustBox owns the squashed baseline and future migration numbering); +- `tests/` (RustBox keeps native Rust/runtime fixtures rather than importing upstream Python contracts); - `src/web/`; - `src/ui.rs`; - RustBox UI routing and same-origin behavior; diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..eda9f66 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,52 @@ +# Database schemas and migrations + +RustBox 0.1.1 has one database backend for the whole application: + +```text +database_backend = sqlite | mysql | pgsql +session_backend = database | redis +``` + +When `session_backend = "database"`, sessions live in the same configured SQL database as the application data. `session_backend = "redis"` moves only session persistence to Redis; Redis Sentinel is a Redis discovery option and has no SQL schema. + +## Layout + +```text +migrations/ +├── sqlite/ +│ └── schema.sql +├── mysql/ +│ └── schema.sql +└── pgsql/ + └── schema.sql +``` + +Every `schema.sql` is the complete schema for that engine. It contains: + +- global tables (`sessions`, `rustbox_accounts`), and +- the full account-data baseline at schema version 46. + +Account data is isolated inside the selected physical database with a deterministic, collision-checked table prefix derived from the account hash. The `{{p}}` marker in the tenant part of each schema is replaced by RustBox at provisioning time. Sessions are global and therefore are not prefixed. + +The SQLite baseline is structurally equivalent to the legacy V001-V046 migration chain. On first access, RustBox 0.1.1 imports an existing legacy `data_dir//db.sqlite` at `PRAGMA user_version = 46` into the configured backend. Only after a successful import is the legacy file renamed to `db.sqlite.pre-0.1.1.bak`. Older legacy schemas must first be upgraded with RustBox 0.1.0. + +## Future migrations + +The next application schema change is **V047**. A schema-changing release must add the same logical migration for every supported SQL engine, for example: + +```text +migrations/sqlite/V047__description.sql +migrations/mysql/V047__description.sql +migrations/pgsql/V047__description.sql +``` + +Tenant migration SQL uses `{{p}}` for account tables. Register each new migration in the corresponding migration array in `src/db/pool.rs` and keep the version numbers aligned across all three engines. Do not edit `schema.sql` alone for an upgrade that must work on existing installations; update both the baseline and the versioned migration. + +Redis and Redis Sentinel do not use SQL migrations. + +## Engine notes + +- SQLite uses `data_dir/rustbox.sqlite` by default; `database_url` may point to another persistent SQLite file. +- MySQL requires `database_url` and targets MySQL 8.0.13 or newer. +- PostgreSQL requires `database_url`. +- MySQL/PostgreSQL are full application backends, not session-only databases. diff --git a/migrations/mysql/V047__sessions.sql b/migrations/mysql/V047__sessions.sql deleted file mode 100644 index 13d1b2d..0000000 --- a/migrations/mysql/V047__sessions.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS sessions ( - token VARCHAR(64) PRIMARY KEY, - account_id VARCHAR(64) NOT NULL UNIQUE, - browser_id VARCHAR(64) NOT NULL, - email VARCHAR(320) NOT NULL, - password_ciphertext TEXT NOT NULL, - user_hash VARCHAR(128) NOT NULL, - imap_host VARCHAR(255) NOT NULL, - imap_port BIGINT NOT NULL, - imap_tls BIGINT NOT NULL, - imap_tls_verify BIGINT NOT NULL, - smtp_host VARCHAR(255) NOT NULL, - smtp_port BIGINT NOT NULL, - smtp_tls BIGINT NOT NULL, - smtp_tls_verify BIGINT NOT NULL, - timeout_secs BIGINT NOT NULL, - created_at_unix BIGINT NOT NULL, - last_accessed_unix BIGINT NOT NULL, - user_agent VARCHAR(512) NOT NULL DEFAULT '', - INDEX idx_sessions_browser (browser_id), - INDEX idx_sessions_user (user_hash), - INDEX idx_sessions_expiry (last_accessed_unix, timeout_secs) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/mysql/schema.sql b/migrations/mysql/schema.sql new file mode 100644 index 0000000..7ca0974 --- /dev/null +++ b/migrations/mysql/schema.sql @@ -0,0 +1,424 @@ +-- RustBox 0.1.1 MySQL schema. +-- One physical database contains global sessions plus isolated per-account tables. +-- Application tables are materialized by replacing {{p}} with a collision-checked prefix. +-- Baseline application schema version: 46. Future migrations start at V047. + +-- RUSTBOX:GLOBAL-BEGIN +CREATE TABLE IF NOT EXISTS rustbox_accounts ( + user_hash VARCHAR(128) PRIMARY KEY, + table_prefix VARCHAR(64) NOT NULL UNIQUE, + schema_version BIGINT NOT NULL DEFAULT 46, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(64) PRIMARY KEY, + account_id VARCHAR(64) NOT NULL UNIQUE, + browser_id VARCHAR(64) NOT NULL, + email VARCHAR(320) NOT NULL, + password_ciphertext TEXT NOT NULL, + user_hash VARCHAR(128) NOT NULL, + imap_host VARCHAR(255) NOT NULL, + imap_port BIGINT NOT NULL, + imap_tls BIGINT NOT NULL, + imap_tls_verify BIGINT NOT NULL, + smtp_host VARCHAR(255) NOT NULL, + smtp_port BIGINT NOT NULL, + smtp_tls BIGINT NOT NULL, + smtp_tls_verify BIGINT NOT NULL, + timeout_secs BIGINT NOT NULL, + created_at_unix BIGINT NOT NULL, + last_accessed_unix BIGINT NOT NULL, + user_agent VARCHAR(512) NOT NULL DEFAULT '', + INDEX idx_sessions_browser (browser_id), + INDEX idx_sessions_user (user_hash), + INDEX idx_sessions_expiry (last_accessed_unix, timeout_secs) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- RUSTBOX:GLOBAL-END + +-- RUSTBOX:TENANT-BEGIN +-- {{p}} is replaced at runtime with the account table prefix. +CREATE TABLE {{p}}calendar_events ( + id VARCHAR(255) PRIMARY KEY, + title TEXT NOT NULL, + description LONGTEXT NOT NULL DEFAULT (''), + location TEXT NOT NULL DEFAULT (''), + start_time VARCHAR(255) NOT NULL, + end_time VARCHAR(255) NOT NULL, + all_day BIGINT NOT NULL DEFAULT 0, + recurrence_rule TEXT, + attendees LONGTEXT NOT NULL DEFAULT ('[]'), + organizer TEXT NOT NULL DEFAULT (''), + status TEXT NOT NULL DEFAULT ('confirmed'), + source TEXT NOT NULL DEFAULT ('manual'), + source_uid VARCHAR(255), + source_message_uid BIGINT, + source_message_folder TEXT, + meeting_url TEXT, + color TEXT, + reminder_minutes BIGINT, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}calendar_settings ( + id BIGINT DEFAULT 1 PRIMARY KEY, + default_view TEXT NOT NULL DEFAULT ('month'), + week_starts_on BIGINT NOT NULL DEFAULT 0, + working_hours_start TEXT NOT NULL DEFAULT ('09:00'), + working_hours_end TEXT NOT NULL DEFAULT ('17:00'), + time_format TEXT NOT NULL DEFAULT ('12h'), + zoom_link TEXT NOT NULL DEFAULT (''), + teams_link TEXT NOT NULL DEFAULT (''), + meet_link TEXT NOT NULL DEFAULT (''), + caldav_url TEXT NOT NULL DEFAULT (''), + caldav_username TEXT NOT NULL DEFAULT (''), + caldav_password TEXT NOT NULL DEFAULT (''), + caldav_enabled BIGINT NOT NULL DEFAULT 0, + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}contact_group_members ( + group_id VARCHAR(255) NOT NULL, + contact_id VARCHAR(255) NOT NULL, + PRIMARY KEY (group_id, contact_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}contact_groups ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(512) NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}contact_photos ( + contact_id VARCHAR(255) PRIMARY KEY, + content_type TEXT NOT NULL, + data LONGBLOB NOT NULL, + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}contacts ( + id VARCHAR(255) PRIMARY KEY, + email VARCHAR(320) NOT NULL, + name VARCHAR(512) NOT NULL DEFAULT '', + company TEXT NOT NULL DEFAULT (''), + notes LONGTEXT NOT NULL DEFAULT (''), + is_favorite BIGINT NOT NULL DEFAULT 0, + last_contacted VARCHAR(64), + contact_count BIGINT NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT ('manual'), + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + phone TEXT NOT NULL DEFAULT (''), + job_title TEXT NOT NULL DEFAULT (''), + address TEXT NOT NULL DEFAULT (''), + website TEXT NOT NULL DEFAULT (''), + birthday TEXT NOT NULL DEFAULT (''), + emails_json LONGTEXT NOT NULL DEFAULT ('[]'), + phones_json LONGTEXT NOT NULL DEFAULT ('[]'), + addresses_json LONGTEXT NOT NULL DEFAULT ('[]') +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}display_preferences ( + id BIGINT DEFAULT 1 PRIMARY KEY, + density TEXT NOT NULL DEFAULT ('comfortable'), + theme TEXT NOT NULL DEFAULT ('system'), + language TEXT NOT NULL DEFAULT ('en'), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + compose_format TEXT NOT NULL DEFAULT ('html'), + deep_index BIGINT NOT NULL DEFAULT 0, + animation_mode TEXT, + preview_pane TEXT NOT NULL DEFAULT ('right'), + ui_scale TEXT NOT NULL DEFAULT ('normal'), + color_preset TEXT NOT NULL DEFAULT ('sage'), + accent_color TEXT NOT NULL DEFAULT ('#6f9c86'), + font_family TEXT NOT NULL DEFAULT ('system'), + font_size BIGINT NOT NULL DEFAULT 13, + row_height BIGINT NOT NULL DEFAULT 58, + folder_pane_width BIGINT NOT NULL DEFAULT 228, + message_pane_width BIGINT NOT NULL DEFAULT 420, + border_radius BIGINT NOT NULL DEFAULT 4, + contrast TEXT NOT NULL DEFAULT ('normal'), + show_avatars BIGINT NOT NULL DEFAULT 1, + show_attachment_icons BIGINT NOT NULL DEFAULT 1, + show_flags BIGINT NOT NULL DEFAULT 1, + reduce_motion BIGINT NOT NULL DEFAULT 0, + date_format TEXT NOT NULL DEFAULT ('locale'), + time_format TEXT NOT NULL DEFAULT ('locale'), + background_color TEXT NOT NULL DEFAULT ('#121513'), + panel_color TEXT NOT NULL DEFAULT ('#181c19'), + text_color TEXT NOT NULL DEFAULT ('#edf0ec'), + compose_autosave_ms BIGINT NOT NULL DEFAULT 3000, + default_priority TEXT NOT NULL DEFAULT ('normal'), + request_mdn_default BIGINT NOT NULL DEFAULT 0, + attachment_reminder BIGINT NOT NULL DEFAULT 1, + icon_size BIGINT NOT NULL DEFAULT 18, + toolbar_density TEXT NOT NULL DEFAULT ('normal'), + sidebar_item_height BIGINT NOT NULL DEFAULT 29, + reader_max_width BIGINT NOT NULL DEFAULT 0, + composer_width BIGINT NOT NULL DEFAULT 900, + selection_color TEXT NOT NULL DEFAULT ('#28352e'), + unread_font_weight BIGINT NOT NULL DEFAULT 730, + unread_badge_style TEXT NOT NULL DEFAULT ('dot'), + compact_headers BIGINT NOT NULL DEFAULT 0, + show_borders BIGINT NOT NULL DEFAULT 1, + scrollbar_density TEXT NOT NULL DEFAULT ('thin'), + monochrome BIGINT NOT NULL DEFAULT 0, + folder_background TEXT NOT NULL DEFAULT (''), + message_background TEXT NOT NULL DEFAULT (''), + reader_background TEXT NOT NULL DEFAULT (''), + compose_font_family TEXT NOT NULL DEFAULT ('system'), + reply_position TEXT NOT NULL DEFAULT ('above'), + quote_style TEXT NOT NULL DEFAULT ('standard'), + forward_mode TEXT NOT NULL DEFAULT ('inline'), + spellcheck_enabled BIGINT NOT NULL DEFAULT 1, + interface_skin TEXT NOT NULL DEFAULT ('rustbox'), + preset_scale_unlocked BIGINT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}draft_attachments ( + id VARCHAR(255) PRIMARY KEY, + draft_id VARCHAR(255) NOT NULL, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size BIGINT NOT NULL, + file_path TEXT NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}drafts ( + id VARCHAR(255) PRIMARY KEY, + to_addresses LONGTEXT NOT NULL DEFAULT (''), + cc_addresses LONGTEXT NOT NULL DEFAULT (''), + bcc_addresses LONGTEXT NOT NULL DEFAULT (''), + subject TEXT NOT NULL DEFAULT (''), + text_body LONGTEXT NOT NULL DEFAULT (''), + html_body LONGTEXT, + in_reply_to TEXT, + references_header LONGTEXT, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + request_mdn BIGINT NOT NULL DEFAULT 0, + priority TEXT NOT NULL DEFAULT ('normal'), + compose_format TEXT NOT NULL DEFAULT ('html'), + remote_folder TEXT, + remote_uid BIGINT, + remote_uid_validity BIGINT, + pgp_mode TEXT NOT NULL DEFAULT ('none'), + pgp_inline BIGINT NOT NULL DEFAULT 0, + pgp_attach_public_key BIGINT NOT NULL DEFAULT 0, + pgp_attach_recipient_keys BIGINT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}folder_preferences ( + folder_name VARCHAR(512) PRIMARY KEY, + flagged_on_top BIGINT, + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}folders ( + name VARCHAR(512) PRIMARY KEY, + delimiter TEXT, + parent TEXT, + flags TEXT NOT NULL DEFAULT (''), + is_subscribed BIGINT NOT NULL DEFAULT 1, + total_count BIGINT NOT NULL DEFAULT 0, + unread_count BIGINT NOT NULL DEFAULT 0, + uid_validity BIGINT NOT NULL DEFAULT 0, + highest_modseq BIGINT NOT NULL DEFAULT 0, + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + messages_updated_at VARCHAR(64) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}identities ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT (''), + email TEXT NOT NULL, + signature_html LONGTEXT NOT NULL DEFAULT (''), + is_default BIGINT NOT NULL DEFAULT 0, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + reply_to TEXT NOT NULL DEFAULT (''), + bcc TEXT NOT NULL DEFAULT (''), + organization TEXT NOT NULL DEFAULT (''), + signature_text LONGTEXT NOT NULL DEFAULT (''), + signature_new BIGINT NOT NULL DEFAULT 1, + signature_reply BIGINT NOT NULL DEFAULT 1, + signature_forward BIGINT NOT NULL DEFAULT 1, + signature_position TEXT NOT NULL DEFAULT ('above'), + pgp_fingerprint TEXT, + pgp_sign_default BIGINT NOT NULL DEFAULT 0, + pgp_encrypt_default BIGINT NOT NULL DEFAULT 0, + pgp_inline_default BIGINT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}known_addresses ( + email VARCHAR(320) NOT NULL PRIMARY KEY, + name VARCHAR(512) NOT NULL DEFAULT '' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}mail_preferences ( + id BIGINT PRIMARY KEY, + messages_per_page BIGINT NOT NULL DEFAULT 50, + mark_read_delay_ms BIGINT NOT NULL DEFAULT 0, + remote_images TEXT NOT NULL DEFAULT ('ask'), + preferred_body TEXT NOT NULL DEFAULT ('auto'), + thread_mode TEXT NOT NULL DEFAULT ('threaded'), + collapse_quotes BIGINT NOT NULL DEFAULT 1, + show_snippets BIGINT NOT NULL DEFAULT 1, + default_sort TEXT NOT NULL DEFAULT ('date_desc'), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + flagged_on_top BIGINT NOT NULL DEFAULT 0, + sync_imap_keywords BIGINT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}meeting_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name TEXT NOT NULL, + url_template TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT (''), + is_default BIGINT NOT NULL DEFAULT 0, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}message_tags ( + tag_id VARCHAR(128) NOT NULL, + message_uid BIGINT NOT NULL, + message_folder VARCHAR(512) NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + PRIMARY KEY (tag_id, message_uid, message_folder) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}messages ( + uid BIGINT NOT NULL, + folder VARCHAR(512) NOT NULL, + message_id VARCHAR(255), + in_reply_to VARCHAR(255), + subject TEXT NOT NULL DEFAULT (''), + from_address TEXT NOT NULL DEFAULT (''), + from_name TEXT NOT NULL DEFAULT (''), + to_addresses LONGTEXT NOT NULL DEFAULT (''), + cc_addresses LONGTEXT NOT NULL DEFAULT (''), + date TEXT NOT NULL, + flags TEXT NOT NULL DEFAULT (''), + size BIGINT NOT NULL DEFAULT 0, + has_attachments BIGINT NOT NULL DEFAULT 0, + snippet TEXT NOT NULL DEFAULT (''), + body_cached BIGINT NOT NULL DEFAULT 0, + body_html LONGTEXT, + body_text LONGTEXT, + references_header LONGTEXT, + date_epoch BIGINT NOT NULL DEFAULT 0, + attachments_json LONGTEXT, + raw_headers LONGTEXT, + reaction TEXT, + email_theme BIGINT, + PRIMARY KEY (uid, folder) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}notification_preferences ( + id BIGINT DEFAULT 1 PRIMARY KEY, + enabled BIGINT NOT NULL DEFAULT 1, + sound BIGINT NOT NULL DEFAULT 0, + folders TEXT NOT NULL DEFAULT ('["INBOX"]'), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + dnd_enabled BIGINT NOT NULL DEFAULT 0, + dnd_start TEXT NOT NULL DEFAULT ('22:00'), + dnd_end TEXT NOT NULL DEFAULT ('07:00') +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}pgp_keys ( + fingerprint VARCHAR(255) PRIMARY KEY, + key_id TEXT NOT NULL, + email TEXT NOT NULL DEFAULT (''), + user_id TEXT NOT NULL DEFAULT (''), + public_key_armor LONGTEXT NOT NULL, + secret_key_path TEXT, + has_secret BIGINT NOT NULL DEFAULT 0, + trust TEXT NOT NULL DEFAULT ('unknown'), + source TEXT NOT NULL DEFAULT ('import'), + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}pgp_preferences ( + id BIGINT PRIMARY KEY, + wkd_enabled BIGINT NOT NULL DEFAULT 1, + hkp_enabled BIGINT NOT NULL DEFAULT 0, + require_trusted_encryption BIGINT NOT NULL DEFAULT 0, + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}response_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(512) NOT NULL, + body TEXT NOT NULL DEFAULT (''), + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + format TEXT NOT NULL DEFAULT ('text'), + UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}saved_searches ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(512) NOT NULL, + query_string LONGTEXT NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}special_folders ( + role VARCHAR(255) PRIMARY KEY, + folder_name TEXT NOT NULL, + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}tags ( + id VARCHAR(128) PRIMARY KEY, + name VARCHAR(512) NOT NULL, + color TEXT NOT NULL DEFAULT ('#6b7280'), + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)), + imap_keyword VARCHAR(255) NOT NULL DEFAULT '', + UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}trusted_senders ( + email VARCHAR(320) PRIMARY KEY, + created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE {{p}}user_meta ( + key VARCHAR(255) PRIMARY KEY, + value TEXT NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE {{p}}contact_group_members ADD CONSTRAINT {{p}}fk_contact_group_members_0 FOREIGN KEY (contact_id) REFERENCES {{p}}contacts (id) ON DELETE CASCADE; +ALTER TABLE {{p}}contact_group_members ADD CONSTRAINT {{p}}fk_contact_group_members_1 FOREIGN KEY (group_id) REFERENCES {{p}}contact_groups (id) ON DELETE CASCADE; +ALTER TABLE {{p}}contact_photos ADD CONSTRAINT {{p}}fk_contact_photos_0 FOREIGN KEY (contact_id) REFERENCES {{p}}contacts (id) ON DELETE CASCADE; +ALTER TABLE {{p}}draft_attachments ADD CONSTRAINT {{p}}fk_draft_attachments_0 FOREIGN KEY (draft_id) REFERENCES {{p}}drafts (id) ON DELETE CASCADE; +ALTER TABLE {{p}}folder_preferences ADD CONSTRAINT {{p}}fk_folder_preferences_0 FOREIGN KEY (folder_name) REFERENCES {{p}}folders (name) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE {{p}}message_tags ADD CONSTRAINT {{p}}fk_message_tags_0 FOREIGN KEY (message_uid, message_folder) REFERENCES {{p}}messages (uid, folder) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE {{p}}message_tags ADD CONSTRAINT {{p}}fk_message_tags_1 FOREIGN KEY (tag_id) REFERENCES {{p}}tags (id) ON DELETE CASCADE; +ALTER TABLE {{p}}messages ADD CONSTRAINT {{p}}fk_messages_0 FOREIGN KEY (folder) REFERENCES {{p}}folders (name) ON DELETE CASCADE; + +CREATE INDEX {{p}}idx_calendar_events_source_uid ON {{p}}calendar_events (source_uid); +CREATE INDEX {{p}}idx_calendar_events_time ON {{p}}calendar_events (start_time, end_time); +CREATE INDEX {{p}}idx_cgm_contact_id ON {{p}}contact_group_members (contact_id); +CREATE INDEX {{p}}idx_contacts_contact_count ON {{p}}contacts (contact_count DESC); +CREATE UNIQUE INDEX {{p}}idx_contacts_email ON {{p}}contacts (email); +CREATE INDEX {{p}}idx_contacts_last_contacted ON {{p}}contacts (last_contacted DESC); +CREATE INDEX {{p}}idx_contacts_name ON {{p}}contacts (name); +CREATE INDEX {{p}}idx_known_addresses_name ON {{p}}known_addresses (name); +CREATE INDEX {{p}}idx_message_tags_message ON {{p}}message_tags (message_uid, message_folder); +CREATE INDEX {{p}}idx_message_tags_tag ON {{p}}message_tags (tag_id); +CREATE INDEX {{p}}idx_messages_folder_date ON {{p}}messages (folder, date_epoch DESC); +CREATE INDEX {{p}}idx_messages_in_reply_to ON {{p}}messages (in_reply_to); +CREATE INDEX {{p}}idx_messages_message_id ON {{p}}messages (message_id); +CREATE INDEX {{p}}idx_pgp_keys_email ON {{p}}pgp_keys (email); +CREATE UNIQUE INDEX {{p}}idx_saved_searches_name ON {{p}}saved_searches (name); + +-- RUSTBOX:TENANT-END diff --git a/migrations/pgsql/V047__sessions.sql b/migrations/pgsql/V047__sessions.sql deleted file mode 100644 index 2edb112..0000000 --- a/migrations/pgsql/V047__sessions.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS sessions ( - token VARCHAR(64) PRIMARY KEY, - account_id VARCHAR(64) NOT NULL UNIQUE, - browser_id VARCHAR(64) NOT NULL, - email VARCHAR(320) NOT NULL, - password_ciphertext TEXT NOT NULL, - user_hash VARCHAR(128) NOT NULL, - imap_host VARCHAR(255) NOT NULL, - imap_port BIGINT NOT NULL, - imap_tls BIGINT NOT NULL, - imap_tls_verify BIGINT NOT NULL, - smtp_host VARCHAR(255) NOT NULL, - smtp_port BIGINT NOT NULL, - smtp_tls BIGINT NOT NULL, - smtp_tls_verify BIGINT NOT NULL, - timeout_secs BIGINT NOT NULL, - created_at_unix BIGINT NOT NULL, - last_accessed_unix BIGINT NOT NULL, - user_agent VARCHAR(512) NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS idx_sessions_browser ON sessions(browser_id); -CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_hash); -CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(last_accessed_unix, timeout_secs); diff --git a/migrations/pgsql/schema.sql b/migrations/pgsql/schema.sql new file mode 100644 index 0000000..3b2b067 --- /dev/null +++ b/migrations/pgsql/schema.sql @@ -0,0 +1,425 @@ +-- RustBox 0.1.1 PostgreSQL schema. +-- One physical database contains global sessions plus isolated per-account tables. +-- Application tables are materialized by replacing {{p}} with a collision-checked prefix. +-- Baseline application schema version: 46. Future migrations start at V047. + +-- RUSTBOX:GLOBAL-BEGIN +CREATE TABLE IF NOT EXISTS rustbox_accounts ( + user_hash VARCHAR(128) PRIMARY KEY, + table_prefix VARCHAR(64) NOT NULL UNIQUE, + schema_version BIGINT NOT NULL DEFAULT 46, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(64) PRIMARY KEY, + account_id VARCHAR(64) NOT NULL UNIQUE, + browser_id VARCHAR(64) NOT NULL, + email VARCHAR(320) NOT NULL, + password_ciphertext TEXT NOT NULL, + user_hash VARCHAR(128) NOT NULL, + imap_host VARCHAR(255) NOT NULL, + imap_port BIGINT NOT NULL, + imap_tls BIGINT NOT NULL, + imap_tls_verify BIGINT NOT NULL, + smtp_host VARCHAR(255) NOT NULL, + smtp_port BIGINT NOT NULL, + smtp_tls BIGINT NOT NULL, + smtp_tls_verify BIGINT NOT NULL, + timeout_secs BIGINT NOT NULL, + created_at_unix BIGINT NOT NULL, + last_accessed_unix BIGINT NOT NULL, + user_agent VARCHAR(512) NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_sessions_browser ON sessions(browser_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(last_accessed_unix, timeout_secs); +-- RUSTBOX:GLOBAL-END + +-- RUSTBOX:TENANT-BEGIN +-- {{p}} is replaced at runtime with the account table prefix. +CREATE TABLE {{p}}calendar_events ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + all_day BIGINT NOT NULL DEFAULT 0, + recurrence_rule TEXT, + attendees TEXT NOT NULL DEFAULT '[]', + organizer TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'confirmed', + source TEXT NOT NULL DEFAULT 'manual', + source_uid TEXT, + source_message_uid BIGINT, + source_message_folder TEXT, + meeting_url TEXT, + color TEXT, + reminder_minutes BIGINT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}calendar_settings ( + id BIGINT DEFAULT 1 PRIMARY KEY, + default_view TEXT NOT NULL DEFAULT 'month', + week_starts_on BIGINT NOT NULL DEFAULT 0, + working_hours_start TEXT NOT NULL DEFAULT '09:00', + working_hours_end TEXT NOT NULL DEFAULT '17:00', + time_format TEXT NOT NULL DEFAULT '12h', + zoom_link TEXT NOT NULL DEFAULT '', + teams_link TEXT NOT NULL DEFAULT '', + meet_link TEXT NOT NULL DEFAULT '', + caldav_url TEXT NOT NULL DEFAULT '', + caldav_username TEXT NOT NULL DEFAULT '', + caldav_password TEXT NOT NULL DEFAULT '', + caldav_enabled BIGINT NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}contact_group_members ( + group_id TEXT NOT NULL, + contact_id TEXT NOT NULL, + PRIMARY KEY (group_id, contact_id) +); + +CREATE TABLE {{p}}contact_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + UNIQUE (name) +); + +CREATE TABLE {{p}}contact_photos ( + contact_id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + data BYTEA NOT NULL, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}contacts ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + company TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + is_favorite BIGINT NOT NULL DEFAULT 0, + last_contacted TEXT, + contact_count BIGINT NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'manual', + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + phone TEXT NOT NULL DEFAULT '', + job_title TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + birthday TEXT NOT NULL DEFAULT '', + emails_json TEXT NOT NULL DEFAULT '[]', + phones_json TEXT NOT NULL DEFAULT '[]', + addresses_json TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE {{p}}display_preferences ( + id BIGINT DEFAULT 1 PRIMARY KEY, + density TEXT NOT NULL DEFAULT 'comfortable', + theme TEXT NOT NULL DEFAULT 'system', + language TEXT NOT NULL DEFAULT 'en', + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + compose_format TEXT NOT NULL DEFAULT 'html', + deep_index BIGINT NOT NULL DEFAULT 0, + animation_mode TEXT, + preview_pane TEXT NOT NULL DEFAULT 'right', + ui_scale TEXT NOT NULL DEFAULT 'normal', + color_preset TEXT NOT NULL DEFAULT 'sage', + accent_color TEXT NOT NULL DEFAULT '#6f9c86', + font_family TEXT NOT NULL DEFAULT 'system', + font_size BIGINT NOT NULL DEFAULT 13, + row_height BIGINT NOT NULL DEFAULT 58, + folder_pane_width BIGINT NOT NULL DEFAULT 228, + message_pane_width BIGINT NOT NULL DEFAULT 420, + border_radius BIGINT NOT NULL DEFAULT 4, + contrast TEXT NOT NULL DEFAULT 'normal', + show_avatars BIGINT NOT NULL DEFAULT 1, + show_attachment_icons BIGINT NOT NULL DEFAULT 1, + show_flags BIGINT NOT NULL DEFAULT 1, + reduce_motion BIGINT NOT NULL DEFAULT 0, + date_format TEXT NOT NULL DEFAULT 'locale', + time_format TEXT NOT NULL DEFAULT 'locale', + background_color TEXT NOT NULL DEFAULT '#121513', + panel_color TEXT NOT NULL DEFAULT '#181c19', + text_color TEXT NOT NULL DEFAULT '#edf0ec', + compose_autosave_ms BIGINT NOT NULL DEFAULT 3000, + default_priority TEXT NOT NULL DEFAULT 'normal', + request_mdn_default BIGINT NOT NULL DEFAULT 0, + attachment_reminder BIGINT NOT NULL DEFAULT 1, + icon_size BIGINT NOT NULL DEFAULT 18, + toolbar_density TEXT NOT NULL DEFAULT 'normal', + sidebar_item_height BIGINT NOT NULL DEFAULT 29, + reader_max_width BIGINT NOT NULL DEFAULT 0, + composer_width BIGINT NOT NULL DEFAULT 900, + selection_color TEXT NOT NULL DEFAULT '#28352e', + unread_font_weight BIGINT NOT NULL DEFAULT 730, + unread_badge_style TEXT NOT NULL DEFAULT 'dot', + compact_headers BIGINT NOT NULL DEFAULT 0, + show_borders BIGINT NOT NULL DEFAULT 1, + scrollbar_density TEXT NOT NULL DEFAULT 'thin', + monochrome BIGINT NOT NULL DEFAULT 0, + folder_background TEXT NOT NULL DEFAULT '', + message_background TEXT NOT NULL DEFAULT '', + reader_background TEXT NOT NULL DEFAULT '', + compose_font_family TEXT NOT NULL DEFAULT 'system', + reply_position TEXT NOT NULL DEFAULT 'above', + quote_style TEXT NOT NULL DEFAULT 'standard', + forward_mode TEXT NOT NULL DEFAULT 'inline', + spellcheck_enabled BIGINT NOT NULL DEFAULT 1, + interface_skin TEXT NOT NULL DEFAULT 'rustbox', + preset_scale_unlocked BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE {{p}}draft_attachments ( + id TEXT PRIMARY KEY, + draft_id TEXT NOT NULL, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size BIGINT NOT NULL, + file_path TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}drafts ( + id TEXT PRIMARY KEY, + to_addresses TEXT NOT NULL DEFAULT '', + cc_addresses TEXT NOT NULL DEFAULT '', + bcc_addresses TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL DEFAULT '', + text_body TEXT NOT NULL DEFAULT '', + html_body TEXT, + in_reply_to TEXT, + references_header TEXT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + request_mdn BIGINT NOT NULL DEFAULT 0, + priority TEXT NOT NULL DEFAULT 'normal', + compose_format TEXT NOT NULL DEFAULT 'html', + remote_folder TEXT, + remote_uid BIGINT, + remote_uid_validity BIGINT, + pgp_mode TEXT NOT NULL DEFAULT 'none', + pgp_inline BIGINT NOT NULL DEFAULT 0, + pgp_attach_public_key BIGINT NOT NULL DEFAULT 0, + pgp_attach_recipient_keys BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE {{p}}folder_preferences ( + folder_name TEXT PRIMARY KEY, + flagged_on_top BIGINT, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}folders ( + name TEXT PRIMARY KEY, + delimiter TEXT, + parent TEXT, + flags TEXT NOT NULL DEFAULT '', + is_subscribed BIGINT NOT NULL DEFAULT 1, + total_count BIGINT NOT NULL DEFAULT 0, + unread_count BIGINT NOT NULL DEFAULT 0, + uid_validity BIGINT NOT NULL DEFAULT 0, + highest_modseq BIGINT NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + messages_updated_at TEXT +); + +CREATE TABLE {{p}}identities ( + id BIGSERIAL PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL, + signature_html TEXT NOT NULL DEFAULT '', + is_default BIGINT NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + reply_to TEXT NOT NULL DEFAULT '', + bcc TEXT NOT NULL DEFAULT '', + organization TEXT NOT NULL DEFAULT '', + signature_text TEXT NOT NULL DEFAULT '', + signature_new BIGINT NOT NULL DEFAULT 1, + signature_reply BIGINT NOT NULL DEFAULT 1, + signature_forward BIGINT NOT NULL DEFAULT 1, + signature_position TEXT NOT NULL DEFAULT 'above', + pgp_fingerprint TEXT, + pgp_sign_default BIGINT NOT NULL DEFAULT 0, + pgp_encrypt_default BIGINT NOT NULL DEFAULT 0, + pgp_inline_default BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE {{p}}known_addresses ( + email TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE {{p}}mail_preferences ( + id BIGINT PRIMARY KEY, + messages_per_page BIGINT NOT NULL DEFAULT 50, + mark_read_delay_ms BIGINT NOT NULL DEFAULT 0, + remote_images TEXT NOT NULL DEFAULT 'ask', + preferred_body TEXT NOT NULL DEFAULT 'auto', + thread_mode TEXT NOT NULL DEFAULT 'threaded', + collapse_quotes BIGINT NOT NULL DEFAULT 1, + show_snippets BIGINT NOT NULL DEFAULT 1, + default_sort TEXT NOT NULL DEFAULT 'date_desc', + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + flagged_on_top BIGINT NOT NULL DEFAULT 0, + sync_imap_keywords BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE {{p}}meeting_templates ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + url_template TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '', + is_default BIGINT NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}message_tags ( + tag_id TEXT NOT NULL, + message_uid BIGINT NOT NULL, + message_folder TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + PRIMARY KEY (tag_id, message_uid, message_folder) +); + +CREATE TABLE {{p}}messages ( + uid BIGINT NOT NULL, + folder TEXT NOT NULL, + message_id TEXT, + in_reply_to TEXT, + subject TEXT NOT NULL DEFAULT '', + from_address TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT '', + to_addresses TEXT NOT NULL DEFAULT '', + cc_addresses TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL, + flags TEXT NOT NULL DEFAULT '', + size BIGINT NOT NULL DEFAULT 0, + has_attachments BIGINT NOT NULL DEFAULT 0, + snippet TEXT NOT NULL DEFAULT '', + body_cached BIGINT NOT NULL DEFAULT 0, + body_html TEXT, + body_text TEXT, + references_header TEXT, + date_epoch BIGINT NOT NULL DEFAULT 0, + attachments_json TEXT, + raw_headers TEXT, + reaction TEXT, + email_theme BIGINT, + PRIMARY KEY (uid, folder) +); + +CREATE TABLE {{p}}notification_preferences ( + id BIGINT DEFAULT 1 PRIMARY KEY, + enabled BIGINT NOT NULL DEFAULT 1, + sound BIGINT NOT NULL DEFAULT 0, + folders TEXT NOT NULL DEFAULT '["INBOX"]', + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + dnd_enabled BIGINT NOT NULL DEFAULT 0, + dnd_start TEXT NOT NULL DEFAULT '22:00', + dnd_end TEXT NOT NULL DEFAULT '07:00' +); + +CREATE TABLE {{p}}pgp_keys ( + fingerprint TEXT PRIMARY KEY, + key_id TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL DEFAULT '', + public_key_armor TEXT NOT NULL, + secret_key_path TEXT, + has_secret BIGINT NOT NULL DEFAULT 0, + trust TEXT NOT NULL DEFAULT 'unknown', + source TEXT NOT NULL DEFAULT 'import', + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}pgp_preferences ( + id BIGINT PRIMARY KEY, + wkd_enabled BIGINT NOT NULL DEFAULT 1, + hkp_enabled BIGINT NOT NULL DEFAULT 0, + require_trusted_encryption BIGINT NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}response_templates ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + format TEXT NOT NULL DEFAULT 'text', + UNIQUE (name) +); + +CREATE TABLE {{p}}saved_searches ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + query_string TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}special_folders ( + role TEXT PRIMARY KEY, + folder_name TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#6b7280', + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + imap_keyword TEXT NOT NULL DEFAULT '', + UNIQUE (name) +); + +CREATE TABLE {{p}}trusted_senders ( + email TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); + +CREATE TABLE {{p}}user_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +ALTER TABLE {{p}}contact_group_members ADD CONSTRAINT {{p}}fk_contact_group_members_0 FOREIGN KEY (contact_id) REFERENCES {{p}}contacts (id) ON DELETE CASCADE; +ALTER TABLE {{p}}contact_group_members ADD CONSTRAINT {{p}}fk_contact_group_members_1 FOREIGN KEY (group_id) REFERENCES {{p}}contact_groups (id) ON DELETE CASCADE; +ALTER TABLE {{p}}contact_photos ADD CONSTRAINT {{p}}fk_contact_photos_0 FOREIGN KEY (contact_id) REFERENCES {{p}}contacts (id) ON DELETE CASCADE; +ALTER TABLE {{p}}draft_attachments ADD CONSTRAINT {{p}}fk_draft_attachments_0 FOREIGN KEY (draft_id) REFERENCES {{p}}drafts (id) ON DELETE CASCADE; +ALTER TABLE {{p}}folder_preferences ADD CONSTRAINT {{p}}fk_folder_preferences_0 FOREIGN KEY (folder_name) REFERENCES {{p}}folders (name) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE {{p}}message_tags ADD CONSTRAINT {{p}}fk_message_tags_0 FOREIGN KEY (message_uid, message_folder) REFERENCES {{p}}messages (uid, folder) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE {{p}}message_tags ADD CONSTRAINT {{p}}fk_message_tags_1 FOREIGN KEY (tag_id) REFERENCES {{p}}tags (id) ON DELETE CASCADE; +ALTER TABLE {{p}}messages ADD CONSTRAINT {{p}}fk_messages_0 FOREIGN KEY (folder) REFERENCES {{p}}folders (name) ON DELETE CASCADE; + +CREATE INDEX {{p}}idx_calendar_events_source_uid ON {{p}}calendar_events (source_uid); +CREATE INDEX {{p}}idx_calendar_events_time ON {{p}}calendar_events (start_time, end_time); +CREATE INDEX {{p}}idx_cgm_contact_id ON {{p}}contact_group_members (contact_id); +CREATE INDEX {{p}}idx_contacts_contact_count ON {{p}}contacts (contact_count DESC); +CREATE UNIQUE INDEX {{p}}idx_contacts_email ON {{p}}contacts (email); +CREATE INDEX {{p}}idx_contacts_last_contacted ON {{p}}contacts (last_contacted DESC); +CREATE INDEX {{p}}idx_contacts_name ON {{p}}contacts (name); +CREATE INDEX {{p}}idx_known_addresses_name ON {{p}}known_addresses (name); +CREATE INDEX {{p}}idx_message_tags_message ON {{p}}message_tags (message_uid, message_folder); +CREATE INDEX {{p}}idx_message_tags_tag ON {{p}}message_tags (tag_id); +CREATE INDEX {{p}}idx_messages_folder_date ON {{p}}messages (folder, date_epoch DESC); +CREATE INDEX {{p}}idx_messages_in_reply_to ON {{p}}messages (in_reply_to); +CREATE INDEX {{p}}idx_messages_message_id ON {{p}}messages (message_id); +CREATE INDEX {{p}}idx_pgp_keys_email ON {{p}}pgp_keys (lower(email)); +CREATE UNIQUE INDEX {{p}}idx_saved_searches_name ON {{p}}saved_searches (name); +CREATE UNIQUE INDEX {{p}}idx_tags_imap_keyword ON {{p}}tags (imap_keyword) WHERE imap_keyword <> ''; + +-- RUSTBOX:TENANT-END diff --git a/migrations/sqlite/V001__initial_schema.sql b/migrations/sqlite/V001__initial_schema.sql deleted file mode 100644 index 203c607..0000000 --- a/migrations/sqlite/V001__initial_schema.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -INSERT OR IGNORE INTO user_meta (key, value) VALUES ('schema_version', '1'); diff --git a/migrations/sqlite/V002__folders_and_messages.sql b/migrations/sqlite/V002__folders_and_messages.sql deleted file mode 100644 index 88c3015..0000000 --- a/migrations/sqlite/V002__folders_and_messages.sql +++ /dev/null @@ -1,41 +0,0 @@ --- Folder cache: stores the IMAP folder hierarchy -CREATE TABLE IF NOT EXISTS folders ( - name TEXT PRIMARY KEY, - delimiter TEXT, - parent TEXT, - flags TEXT NOT NULL DEFAULT '', - is_subscribed INTEGER NOT NULL DEFAULT 1, - total_count INTEGER NOT NULL DEFAULT 0, - unread_count INTEGER NOT NULL DEFAULT 0, - uid_validity INTEGER NOT NULL DEFAULT 0, - highest_modseq INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Message header cache: stores synced email headers per folder -CREATE TABLE IF NOT EXISTS messages ( - uid INTEGER NOT NULL, - folder TEXT NOT NULL, - message_id TEXT, - in_reply_to TEXT, - subject TEXT NOT NULL DEFAULT '', - from_address TEXT NOT NULL DEFAULT '', - from_name TEXT NOT NULL DEFAULT '', - to_addresses TEXT NOT NULL DEFAULT '', - cc_addresses TEXT NOT NULL DEFAULT '', - date TEXT NOT NULL, - flags TEXT NOT NULL DEFAULT '', - size INTEGER NOT NULL DEFAULT 0, - has_attachments INTEGER NOT NULL DEFAULT 0, - snippet TEXT NOT NULL DEFAULT '', - body_cached INTEGER NOT NULL DEFAULT 0, - body_html TEXT, - body_text TEXT, - references_header TEXT, - PRIMARY KEY (uid, folder), - FOREIGN KEY (folder) REFERENCES folders(name) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_messages_folder_date ON messages(folder, date DESC); -CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id); -CREATE INDEX IF NOT EXISTS idx_messages_in_reply_to ON messages(in_reply_to); diff --git a/migrations/sqlite/V003__add_date_epoch.sql b/migrations/sqlite/V003__add_date_epoch.sql deleted file mode 100644 index 5a2fc56..0000000 --- a/migrations/sqlite/V003__add_date_epoch.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Add a sortable integer timestamp column for proper date ordering. -ALTER TABLE messages ADD COLUMN date_epoch INTEGER NOT NULL DEFAULT 0; - --- Rebuild index to use the new column for sorting. -DROP INDEX IF EXISTS idx_messages_folder_date; -CREATE INDEX idx_messages_folder_date ON messages(folder, date_epoch DESC); diff --git a/migrations/sqlite/V004__folder_messages_updated_at.sql b/migrations/sqlite/V004__folder_messages_updated_at.sql deleted file mode 100644 index fff7c73..0000000 --- a/migrations/sqlite/V004__folder_messages_updated_at.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Separate timestamp for when a folder's messages were last synced from IMAP, --- distinct from `updated_at` which tracks when the folder list was last synced. -ALTER TABLE folders ADD COLUMN messages_updated_at TEXT; diff --git a/migrations/sqlite/V005__drafts_and_attachments.sql b/migrations/sqlite/V005__drafts_and_attachments.sql deleted file mode 100644 index 27ef7f1..0000000 --- a/migrations/sqlite/V005__drafts_and_attachments.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE IF NOT EXISTS drafts ( - id TEXT PRIMARY KEY, - to_addresses TEXT NOT NULL DEFAULT '', - cc_addresses TEXT NOT NULL DEFAULT '', - bcc_addresses TEXT NOT NULL DEFAULT '', - subject TEXT NOT NULL DEFAULT '', - text_body TEXT NOT NULL DEFAULT '', - html_body TEXT, - in_reply_to TEXT, - references_header TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS draft_attachments ( - id TEXT PRIMARY KEY, - draft_id TEXT NOT NULL, - filename TEXT NOT NULL, - content_type TEXT NOT NULL, - size INTEGER NOT NULL, - file_path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (draft_id) REFERENCES drafts(id) ON DELETE CASCADE -); diff --git a/migrations/sqlite/V006__cache_attachments_and_headers.sql b/migrations/sqlite/V006__cache_attachments_and_headers.sql deleted file mode 100644 index 492f5b0..0000000 --- a/migrations/sqlite/V006__cache_attachments_and_headers.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Cache attachment metadata and raw headers alongside the message body. -ALTER TABLE messages ADD COLUMN attachments_json TEXT; -ALTER TABLE messages ADD COLUMN raw_headers TEXT; diff --git a/migrations/sqlite/V007__contacts.sql b/migrations/sqlite/V007__contacts.sql deleted file mode 100644 index c37821c..0000000 --- a/migrations/sqlite/V007__contacts.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE IF NOT EXISTS contacts ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL, - name TEXT NOT NULL DEFAULT '', - company TEXT NOT NULL DEFAULT '', - notes TEXT NOT NULL DEFAULT '', - is_favorite INTEGER NOT NULL DEFAULT 0, - last_contacted TEXT, - contact_count INTEGER NOT NULL DEFAULT 0, - source TEXT NOT NULL DEFAULT 'manual', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email); -CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name); -CREATE INDEX IF NOT EXISTS idx_contacts_last_contacted ON contacts(last_contacted DESC); -CREATE INDEX IF NOT EXISTS idx_contacts_contact_count ON contacts(contact_count DESC); diff --git a/migrations/sqlite/V008__identities.sql b/migrations/sqlite/V008__identities.sql deleted file mode 100644 index f144d2e..0000000 --- a/migrations/sqlite/V008__identities.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS identities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - display_name TEXT NOT NULL DEFAULT '', - email TEXT NOT NULL, - signature_html TEXT NOT NULL DEFAULT '', - is_default INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/migrations/sqlite/V009__notification_preferences.sql b/migrations/sqlite/V009__notification_preferences.sql deleted file mode 100644 index fb76df8..0000000 --- a/migrations/sqlite/V009__notification_preferences.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS notification_preferences ( - id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), - enabled INTEGER NOT NULL DEFAULT 1, - sound INTEGER NOT NULL DEFAULT 0, - folders TEXT NOT NULL DEFAULT '["INBOX"]', - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -INSERT OR IGNORE INTO notification_preferences (id) VALUES (1); diff --git a/migrations/sqlite/V010__contact_groups.sql b/migrations/sqlite/V010__contact_groups.sql deleted file mode 100644 index 13ef6d3..0000000 --- a/migrations/sqlite/V010__contact_groups.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE IF NOT EXISTS contact_groups ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS contact_group_members ( - group_id TEXT NOT NULL, - contact_id TEXT NOT NULL, - PRIMARY KEY (group_id, contact_id), - FOREIGN KEY (group_id) REFERENCES contact_groups(id) ON DELETE CASCADE, - FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_cgm_contact_id ON contact_group_members(contact_id); diff --git a/migrations/sqlite/V011__message_reaction.sql b/migrations/sqlite/V011__message_reaction.sql deleted file mode 100644 index e090827..0000000 --- a/migrations/sqlite/V011__message_reaction.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE messages ADD COLUMN reaction TEXT; diff --git a/migrations/sqlite/V012__tags.sql b/migrations/sqlite/V012__tags.sql deleted file mode 100644 index fcdaaa4..0000000 --- a/migrations/sqlite/V012__tags.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - color TEXT NOT NULL DEFAULT '#6b7280', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS message_tags ( - tag_id TEXT NOT NULL, - message_uid INTEGER NOT NULL, - message_folder TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (tag_id, message_uid, message_folder), - FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, - FOREIGN KEY (message_uid, message_folder) REFERENCES messages(uid, folder) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_message_tags_message ON message_tags(message_uid, message_folder); -CREATE INDEX IF NOT EXISTS idx_message_tags_tag ON message_tags(tag_id); diff --git a/migrations/sqlite/V013__display_preferences.sql b/migrations/sqlite/V013__display_preferences.sql deleted file mode 100644 index 1219e63..0000000 --- a/migrations/sqlite/V013__display_preferences.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS display_preferences ( - id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), - density TEXT NOT NULL DEFAULT 'comfortable', - theme TEXT NOT NULL DEFAULT 'system', - language TEXT NOT NULL DEFAULT 'en', - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/migrations/sqlite/V014__compose_format.sql b/migrations/sqlite/V014__compose_format.sql deleted file mode 100644 index 96d09c9..0000000 --- a/migrations/sqlite/V014__compose_format.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN compose_format TEXT NOT NULL DEFAULT 'html'; diff --git a/migrations/sqlite/V015__deep_index.sql b/migrations/sqlite/V015__deep_index.sql deleted file mode 100644 index 61e85b9..0000000 --- a/migrations/sqlite/V015__deep_index.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN deep_index BOOLEAN NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/V016__calendar.sql b/migrations/sqlite/V016__calendar.sql deleted file mode 100644 index 230788d..0000000 --- a/migrations/sqlite/V016__calendar.sql +++ /dev/null @@ -1,51 +0,0 @@ -CREATE TABLE calendar_events ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - location TEXT NOT NULL DEFAULT '', - start_time TEXT NOT NULL, - end_time TEXT NOT NULL, - all_day INTEGER NOT NULL DEFAULT 0, - recurrence_rule TEXT, - attendees TEXT NOT NULL DEFAULT '[]', - organizer TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'confirmed', - source TEXT NOT NULL DEFAULT 'manual', - source_uid TEXT, - source_message_uid INTEGER, - source_message_folder TEXT, - meeting_url TEXT, - color TEXT, - reminder_minutes INTEGER, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX idx_calendar_events_time ON calendar_events(start_time, end_time); -CREATE INDEX idx_calendar_events_source_uid ON calendar_events(source_uid); - -CREATE TABLE calendar_settings ( - id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), - default_view TEXT NOT NULL DEFAULT 'month', - week_starts_on INTEGER NOT NULL DEFAULT 0, - working_hours_start TEXT NOT NULL DEFAULT '09:00', - working_hours_end TEXT NOT NULL DEFAULT '17:00', - time_format TEXT NOT NULL DEFAULT '12h', - zoom_link TEXT NOT NULL DEFAULT '', - teams_link TEXT NOT NULL DEFAULT '', - meet_link TEXT NOT NULL DEFAULT '', - caldav_url TEXT NOT NULL DEFAULT '', - caldav_username TEXT NOT NULL DEFAULT '', - caldav_password TEXT NOT NULL DEFAULT '', - caldav_enabled INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE meeting_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - url_template TEXT NOT NULL, - icon TEXT NOT NULL DEFAULT '', - is_default INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/migrations/sqlite/V017__add_email_theme.sql b/migrations/sqlite/V017__add_email_theme.sql deleted file mode 100644 index d0f1a23..0000000 --- a/migrations/sqlite/V017__add_email_theme.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add email_theme column to messages table --- NULL = not yet detected (only relevant when body_html exists) --- 0 = Light, 1 = Dark, 2 = Transparent -ALTER TABLE messages ADD COLUMN email_theme INTEGER; diff --git a/migrations/sqlite/V018__animation_mode.sql b/migrations/sqlite/V018__animation_mode.sql deleted file mode 100644 index 6fcee36..0000000 --- a/migrations/sqlite/V018__animation_mode.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE display_preferences -ADD COLUMN animation_mode TEXT NULL; diff --git a/migrations/sqlite/V019__known_addresses.sql b/migrations/sqlite/V019__known_addresses.sql deleted file mode 100644 index 7b30a1b..0000000 --- a/migrations/sqlite/V019__known_addresses.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Denormalized table of known email addresses extracted from message headers. --- Avoids expensive json_each() scanning of to_addresses/cc_addresses columns. -CREATE TABLE IF NOT EXISTS known_addresses ( - email TEXT NOT NULL, - name TEXT NOT NULL DEFAULT '', - PRIMARY KEY (email) -); - -CREATE INDEX IF NOT EXISTS idx_known_addresses_name ON known_addresses(name); - --- Backfill from existing messages: from_address -INSERT INTO known_addresses (email, name) -SELECT from_address, COALESCE(from_name, '') -FROM messages -WHERE from_address != '' -ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''; - --- Backfill from existing messages: to_addresses (JSON array) -INSERT INTO known_addresses (email, name) -SELECT value->>'address', COALESCE(value->>'name', '') -FROM messages, json_each(to_addresses) -WHERE json_valid(to_addresses) - AND value->>'address' IS NOT NULL - AND value->>'address' != '' -ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''; - --- Backfill from existing messages: cc_addresses (JSON array) -INSERT INTO known_addresses (email, name) -SELECT value->>'address', COALESCE(value->>'name', '') -FROM messages, json_each(cc_addresses) -WHERE json_valid(cc_addresses) - AND value->>'address' IS NOT NULL - AND value->>'address' != '' -ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''; diff --git a/migrations/sqlite/V020__appearance_personalization.sql b/migrations/sqlite/V020__appearance_personalization.sql deleted file mode 100644 index e230bf6..0000000 --- a/migrations/sqlite/V020__appearance_personalization.sql +++ /dev/null @@ -1,17 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN preview_pane TEXT NOT NULL DEFAULT 'right'; -ALTER TABLE display_preferences ADD COLUMN ui_scale TEXT NOT NULL DEFAULT 'normal'; -ALTER TABLE display_preferences ADD COLUMN color_preset TEXT NOT NULL DEFAULT 'sage'; -ALTER TABLE display_preferences ADD COLUMN accent_color TEXT NOT NULL DEFAULT '#6f9c86'; -ALTER TABLE display_preferences ADD COLUMN font_family TEXT NOT NULL DEFAULT 'system'; -ALTER TABLE display_preferences ADD COLUMN font_size INTEGER NOT NULL DEFAULT 13; -ALTER TABLE display_preferences ADD COLUMN row_height INTEGER NOT NULL DEFAULT 58; -ALTER TABLE display_preferences ADD COLUMN folder_pane_width INTEGER NOT NULL DEFAULT 228; -ALTER TABLE display_preferences ADD COLUMN message_pane_width INTEGER NOT NULL DEFAULT 420; -ALTER TABLE display_preferences ADD COLUMN border_radius INTEGER NOT NULL DEFAULT 4; -ALTER TABLE display_preferences ADD COLUMN contrast TEXT NOT NULL DEFAULT 'normal'; -ALTER TABLE display_preferences ADD COLUMN show_avatars INTEGER NOT NULL DEFAULT 1; -ALTER TABLE display_preferences ADD COLUMN show_attachment_icons INTEGER NOT NULL DEFAULT 1; -ALTER TABLE display_preferences ADD COLUMN show_flags INTEGER NOT NULL DEFAULT 1; -ALTER TABLE display_preferences ADD COLUMN reduce_motion INTEGER NOT NULL DEFAULT 0; -ALTER TABLE display_preferences ADD COLUMN date_format TEXT NOT NULL DEFAULT 'locale'; -ALTER TABLE display_preferences ADD COLUMN time_format TEXT NOT NULL DEFAULT 'locale'; diff --git a/migrations/sqlite/V021__special_folders.sql b/migrations/sqlite/V021__special_folders.sql deleted file mode 100644 index 77941d2..0000000 --- a/migrations/sqlite/V021__special_folders.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS special_folders ( - role TEXT PRIMARY KEY CHECK(role IN ('sent','drafts','trash','junk','archive')), - folder_name TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/migrations/sqlite/V022__identity_options.sql b/migrations/sqlite/V022__identity_options.sql deleted file mode 100644 index 2b574a9..0000000 --- a/migrations/sqlite/V022__identity_options.sql +++ /dev/null @@ -1,8 +0,0 @@ -ALTER TABLE identities ADD COLUMN reply_to TEXT NOT NULL DEFAULT ''; -ALTER TABLE identities ADD COLUMN bcc TEXT NOT NULL DEFAULT ''; -ALTER TABLE identities ADD COLUMN organization TEXT NOT NULL DEFAULT ''; -ALTER TABLE identities ADD COLUMN signature_text TEXT NOT NULL DEFAULT ''; -ALTER TABLE identities ADD COLUMN signature_new INTEGER NOT NULL DEFAULT 1; -ALTER TABLE identities ADD COLUMN signature_reply INTEGER NOT NULL DEFAULT 1; -ALTER TABLE identities ADD COLUMN signature_forward INTEGER NOT NULL DEFAULT 1; -ALTER TABLE identities ADD COLUMN signature_position TEXT NOT NULL DEFAULT 'above'; diff --git a/migrations/sqlite/V023__saved_searches.sql b/migrations/sqlite/V023__saved_searches.sql deleted file mode 100644 index c1d28ed..0000000 --- a/migrations/sqlite/V023__saved_searches.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS saved_searches ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - query_string TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_searches_name ON saved_searches(name COLLATE NOCASE); diff --git a/migrations/sqlite/V024__notification_dnd.sql b/migrations/sqlite/V024__notification_dnd.sql deleted file mode 100644 index ee34f16..0000000 --- a/migrations/sqlite/V024__notification_dnd.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE notification_preferences ADD COLUMN dnd_enabled INTEGER NOT NULL DEFAULT 0; -ALTER TABLE notification_preferences ADD COLUMN dnd_start TEXT NOT NULL DEFAULT '22:00'; -ALTER TABLE notification_preferences ADD COLUMN dnd_end TEXT NOT NULL DEFAULT '07:00'; diff --git a/migrations/sqlite/V025__custom_theme_colors.sql b/migrations/sqlite/V025__custom_theme_colors.sql deleted file mode 100644 index f85c2a6..0000000 --- a/migrations/sqlite/V025__custom_theme_colors.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN background_color TEXT NOT NULL DEFAULT '#121513'; -ALTER TABLE display_preferences ADD COLUMN panel_color TEXT NOT NULL DEFAULT '#181c19'; -ALTER TABLE display_preferences ADD COLUMN text_color TEXT NOT NULL DEFAULT '#edf0ec'; diff --git a/migrations/sqlite/V026__mail_preferences.sql b/migrations/sqlite/V026__mail_preferences.sql deleted file mode 100644 index 048614c..0000000 --- a/migrations/sqlite/V026__mail_preferences.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE IF NOT EXISTS mail_preferences ( - id INTEGER PRIMARY KEY CHECK (id = 1), - messages_per_page INTEGER NOT NULL DEFAULT 50, - mark_read_delay_ms INTEGER NOT NULL DEFAULT 0, - remote_images TEXT NOT NULL DEFAULT 'ask', - preferred_body TEXT NOT NULL DEFAULT 'auto', - thread_mode TEXT NOT NULL DEFAULT 'threaded', - collapse_quotes INTEGER NOT NULL DEFAULT 1, - show_snippets INTEGER NOT NULL DEFAULT 1, - default_sort TEXT NOT NULL DEFAULT 'date_desc', - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -INSERT OR IGNORE INTO mail_preferences (id) VALUES (1); diff --git a/migrations/sqlite/V027__contact_profile.sql b/migrations/sqlite/V027__contact_profile.sql deleted file mode 100644 index 96169b7..0000000 --- a/migrations/sqlite/V027__contact_profile.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE contacts ADD COLUMN phone TEXT NOT NULL DEFAULT ''; -ALTER TABLE contacts ADD COLUMN job_title TEXT NOT NULL DEFAULT ''; -ALTER TABLE contacts ADD COLUMN address TEXT NOT NULL DEFAULT ''; -ALTER TABLE contacts ADD COLUMN website TEXT NOT NULL DEFAULT ''; -ALTER TABLE contacts ADD COLUMN birthday TEXT NOT NULL DEFAULT ''; - -CREATE TABLE IF NOT EXISTS contact_photos ( - contact_id TEXT PRIMARY KEY, - content_type TEXT NOT NULL, - data BLOB NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE -); diff --git a/migrations/sqlite/V028__contact_multi_values.sql b/migrations/sqlite/V028__contact_multi_values.sql deleted file mode 100644 index 2725b0c..0000000 --- a/migrations/sqlite/V028__contact_multi_values.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE contacts ADD COLUMN emails_json TEXT NOT NULL DEFAULT '[]'; -ALTER TABLE contacts ADD COLUMN phones_json TEXT NOT NULL DEFAULT '[]'; -ALTER TABLE contacts ADD COLUMN addresses_json TEXT NOT NULL DEFAULT '[]'; diff --git a/migrations/sqlite/V029__draft_send_options.sql b/migrations/sqlite/V029__draft_send_options.sql deleted file mode 100644 index 0b9b5ec..0000000 --- a/migrations/sqlite/V029__draft_send_options.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE drafts ADD COLUMN request_mdn INTEGER NOT NULL DEFAULT 0; -ALTER TABLE drafts ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal'; diff --git a/migrations/sqlite/V030__folder_list_preferences.sql b/migrations/sqlite/V030__folder_list_preferences.sql deleted file mode 100644 index 2d8c3d8..0000000 --- a/migrations/sqlite/V030__folder_list_preferences.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE mail_preferences ADD COLUMN flagged_on_top INTEGER NOT NULL DEFAULT 0; - -CREATE TABLE IF NOT EXISTS folder_preferences ( - folder_name TEXT PRIMARY KEY REFERENCES folders(name) ON UPDATE CASCADE ON DELETE CASCADE, - flagged_on_top INTEGER CHECK(flagged_on_top IN (0, 1) OR flagged_on_top IS NULL), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/migrations/sqlite/V031__compose_preferences.sql b/migrations/sqlite/V031__compose_preferences.sql deleted file mode 100644 index cc41b71..0000000 --- a/migrations/sqlite/V031__compose_preferences.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN compose_autosave_ms INTEGER NOT NULL DEFAULT 3000; -ALTER TABLE display_preferences ADD COLUMN default_priority TEXT NOT NULL DEFAULT 'normal'; -ALTER TABLE display_preferences ADD COLUMN request_mdn_default INTEGER NOT NULL DEFAULT 0; -ALTER TABLE display_preferences ADD COLUMN attachment_reminder INTEGER NOT NULL DEFAULT 1; diff --git a/migrations/sqlite/V032__advanced_appearance.sql b/migrations/sqlite/V032__advanced_appearance.sql deleted file mode 100644 index 5620d25..0000000 --- a/migrations/sqlite/V032__advanced_appearance.sql +++ /dev/null @@ -1,15 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN icon_size INTEGER NOT NULL DEFAULT 18; -ALTER TABLE display_preferences ADD COLUMN toolbar_density TEXT NOT NULL DEFAULT 'normal'; -ALTER TABLE display_preferences ADD COLUMN sidebar_item_height INTEGER NOT NULL DEFAULT 29; -ALTER TABLE display_preferences ADD COLUMN reader_max_width INTEGER NOT NULL DEFAULT 0; -ALTER TABLE display_preferences ADD COLUMN composer_width INTEGER NOT NULL DEFAULT 900; -ALTER TABLE display_preferences ADD COLUMN selection_color TEXT NOT NULL DEFAULT '#28352e'; -ALTER TABLE display_preferences ADD COLUMN unread_font_weight INTEGER NOT NULL DEFAULT 730; -ALTER TABLE display_preferences ADD COLUMN unread_badge_style TEXT NOT NULL DEFAULT 'dot'; -ALTER TABLE display_preferences ADD COLUMN compact_headers INTEGER NOT NULL DEFAULT 0; -ALTER TABLE display_preferences ADD COLUMN show_borders INTEGER NOT NULL DEFAULT 1; -ALTER TABLE display_preferences ADD COLUMN scrollbar_density TEXT NOT NULL DEFAULT 'thin'; -ALTER TABLE display_preferences ADD COLUMN monochrome INTEGER NOT NULL DEFAULT 0; -ALTER TABLE display_preferences ADD COLUMN folder_background TEXT NOT NULL DEFAULT ''; -ALTER TABLE display_preferences ADD COLUMN message_background TEXT NOT NULL DEFAULT ''; -ALTER TABLE display_preferences ADD COLUMN reader_background TEXT NOT NULL DEFAULT ''; diff --git a/migrations/sqlite/V033__draft_compose_format.sql b/migrations/sqlite/V033__draft_compose_format.sql deleted file mode 100644 index 828b212..0000000 --- a/migrations/sqlite/V033__draft_compose_format.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE drafts ADD COLUMN compose_format TEXT NOT NULL DEFAULT 'html'; diff --git a/migrations/sqlite/V034__imap_keyword_sync.sql b/migrations/sqlite/V034__imap_keyword_sync.sql deleted file mode 100644 index be4cffd..0000000 --- a/migrations/sqlite/V034__imap_keyword_sync.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE mail_preferences ADD COLUMN sync_imap_keywords INTEGER NOT NULL DEFAULT 0; -ALTER TABLE tags ADD COLUMN imap_keyword TEXT NOT NULL DEFAULT ''; -UPDATE tags SET imap_keyword = 'RustBox-' || replace(id, '-', '') WHERE imap_keyword = ''; -CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_imap_keyword ON tags(imap_keyword) WHERE imap_keyword <> ''; diff --git a/migrations/sqlite/V035__compose_behavior_preferences.sql b/migrations/sqlite/V035__compose_behavior_preferences.sql deleted file mode 100644 index 6d93444..0000000 --- a/migrations/sqlite/V035__compose_behavior_preferences.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN compose_font_family TEXT NOT NULL DEFAULT 'system'; -ALTER TABLE display_preferences ADD COLUMN reply_position TEXT NOT NULL DEFAULT 'above'; -ALTER TABLE display_preferences ADD COLUMN quote_style TEXT NOT NULL DEFAULT 'standard'; -ALTER TABLE display_preferences ADD COLUMN forward_mode TEXT NOT NULL DEFAULT 'inline'; -ALTER TABLE display_preferences ADD COLUMN spellcheck_enabled INTEGER NOT NULL DEFAULT 1; diff --git a/migrations/sqlite/V036__remote_draft_tracking.sql b/migrations/sqlite/V036__remote_draft_tracking.sql deleted file mode 100644 index 158d7c1..0000000 --- a/migrations/sqlite/V036__remote_draft_tracking.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE drafts ADD COLUMN remote_folder TEXT; -ALTER TABLE drafts ADD COLUMN remote_uid INTEGER; -ALTER TABLE drafts ADD COLUMN remote_uid_validity INTEGER; diff --git a/migrations/sqlite/V037__openpgp_keyring.sql b/migrations/sqlite/V037__openpgp_keyring.sql deleted file mode 100644 index a06108a..0000000 --- a/migrations/sqlite/V037__openpgp_keyring.sql +++ /dev/null @@ -1,24 +0,0 @@ --- OpenPGP keyring metadata. Secret key material is stored outside SQLite. -CREATE TABLE IF NOT EXISTS pgp_keys ( - fingerprint TEXT PRIMARY KEY, - key_id TEXT NOT NULL, - email TEXT NOT NULL DEFAULT '', - user_id TEXT NOT NULL DEFAULT '', - public_key_armor TEXT NOT NULL, - secret_key_path TEXT, - has_secret INTEGER NOT NULL DEFAULT 0, - trust TEXT NOT NULL DEFAULT 'unknown', - source TEXT NOT NULL DEFAULT 'import', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); -CREATE INDEX IF NOT EXISTS idx_pgp_keys_email ON pgp_keys(lower(email)); - -CREATE TABLE IF NOT EXISTS pgp_preferences ( - id INTEGER PRIMARY KEY CHECK (id = 1), - wkd_enabled INTEGER NOT NULL DEFAULT 1, - hkp_enabled INTEGER NOT NULL DEFAULT 0, - require_trusted_encryption INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); -INSERT OR IGNORE INTO pgp_preferences (id) VALUES (1); diff --git a/migrations/sqlite/V038__identity_openpgp.sql b/migrations/sqlite/V038__identity_openpgp.sql deleted file mode 100644 index 6ed1383..0000000 --- a/migrations/sqlite/V038__identity_openpgp.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE identities ADD COLUMN pgp_fingerprint TEXT; -ALTER TABLE identities ADD COLUMN pgp_sign_default INTEGER NOT NULL DEFAULT 0; -ALTER TABLE identities ADD COLUMN pgp_encrypt_default INTEGER NOT NULL DEFAULT 0; -ALTER TABLE identities ADD COLUMN pgp_inline_default INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/V039__draft_openpgp_options.sql b/migrations/sqlite/V039__draft_openpgp_options.sql deleted file mode 100644 index 8946f48..0000000 --- a/migrations/sqlite/V039__draft_openpgp_options.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE drafts ADD COLUMN pgp_mode TEXT NOT NULL DEFAULT 'none'; -ALTER TABLE drafts ADD COLUMN pgp_inline INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/V040__draft_openpgp_attach_key.sql b/migrations/sqlite/V040__draft_openpgp_attach_key.sql deleted file mode 100644 index 4430ecd..0000000 --- a/migrations/sqlite/V040__draft_openpgp_attach_key.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE drafts ADD COLUMN pgp_attach_public_key INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/V041__draft_openpgp_recipient_keys.sql b/migrations/sqlite/V041__draft_openpgp_recipient_keys.sql deleted file mode 100644 index 2966984..0000000 --- a/migrations/sqlite/V041__draft_openpgp_recipient_keys.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE drafts ADD COLUMN pgp_attach_recipient_keys INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/V042__trusted_senders.sql b/migrations/sqlite/V042__trusted_senders.sql deleted file mode 100644 index 16d4bfb..0000000 --- a/migrations/sqlite/V042__trusted_senders.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE IF NOT EXISTS trusted_senders ( - email TEXT PRIMARY KEY COLLATE NOCASE, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/migrations/sqlite/V043__response_templates.sql b/migrations/sqlite/V043__response_templates.sql deleted file mode 100644 index 723acb0..0000000 --- a/migrations/sqlite/V043__response_templates.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS response_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL COLLATE NOCASE UNIQUE, - body TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/migrations/sqlite/V044__interface_skin.sql b/migrations/sqlite/V044__interface_skin.sql deleted file mode 100644 index 5d8bb36..0000000 --- a/migrations/sqlite/V044__interface_skin.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN interface_skin TEXT NOT NULL DEFAULT 'rustbox'; diff --git a/migrations/sqlite/V045__response_template_format.sql b/migrations/sqlite/V045__response_template_format.sql deleted file mode 100644 index b747076..0000000 --- a/migrations/sqlite/V045__response_template_format.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE response_templates ADD COLUMN format TEXT NOT NULL DEFAULT 'text'; diff --git a/migrations/sqlite/V046__preset_scale_unlock.sql b/migrations/sqlite/V046__preset_scale_unlock.sql deleted file mode 100644 index 1429cc3..0000000 --- a/migrations/sqlite/V046__preset_scale_unlock.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE display_preferences ADD COLUMN preset_scale_unlocked INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/V047__sessions.sql b/migrations/sqlite/V047__sessions.sql deleted file mode 100644 index 49fcada..0000000 --- a/migrations/sqlite/V047__sessions.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS sessions ( - token TEXT PRIMARY KEY NOT NULL, - account_id TEXT NOT NULL UNIQUE, - browser_id TEXT NOT NULL, - email TEXT NOT NULL, - password_ciphertext TEXT NOT NULL, - user_hash TEXT NOT NULL, - imap_host TEXT NOT NULL, - imap_port INTEGER NOT NULL, - imap_tls INTEGER NOT NULL, - imap_tls_verify INTEGER NOT NULL, - smtp_host TEXT NOT NULL, - smtp_port INTEGER NOT NULL, - smtp_tls INTEGER NOT NULL, - smtp_tls_verify INTEGER NOT NULL, - timeout_secs INTEGER NOT NULL, - created_at_unix INTEGER NOT NULL, - last_accessed_unix INTEGER NOT NULL, - user_agent TEXT NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS idx_sessions_browser ON sessions(browser_id); -CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_hash); -CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(last_accessed_unix, timeout_secs); diff --git a/migrations/sqlite/schema.sql b/migrations/sqlite/schema.sql new file mode 100644 index 0000000..1dfaa20 --- /dev/null +++ b/migrations/sqlite/schema.sql @@ -0,0 +1,344 @@ +-- RustBox 0.1.1 SQLite schema. +-- One physical database contains global sessions plus isolated per-account tables. +-- Application tables are materialized from the tenant section by replacing {{p}}. +-- Baseline application schema version: 46. Future migrations start at V047. + +-- RUSTBOX:GLOBAL-BEGIN +CREATE TABLE IF NOT EXISTS rustbox_accounts ( + user_hash TEXT PRIMARY KEY NOT NULL, + table_prefix TEXT NOT NULL UNIQUE, + schema_version INTEGER NOT NULL DEFAULT 46, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL UNIQUE, + browser_id TEXT NOT NULL, + email TEXT NOT NULL, + password_ciphertext TEXT NOT NULL, + user_hash TEXT NOT NULL, + imap_host TEXT NOT NULL, + imap_port INTEGER NOT NULL, + imap_tls INTEGER NOT NULL, + imap_tls_verify INTEGER NOT NULL, + smtp_host TEXT NOT NULL, + smtp_port INTEGER NOT NULL, + smtp_tls INTEGER NOT NULL, + smtp_tls_verify INTEGER NOT NULL, + timeout_secs INTEGER NOT NULL, + created_at_unix INTEGER NOT NULL, + last_accessed_unix INTEGER NOT NULL, + user_agent TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_sessions_browser ON sessions(browser_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(last_accessed_unix, timeout_secs); +-- RUSTBOX:GLOBAL-END + +-- RUSTBOX:TENANT-BEGIN +-- {{p}} is replaced at runtime with a collision-checked per-account table prefix. +CREATE TABLE {{p}}calendar_events ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + all_day INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + attendees TEXT NOT NULL DEFAULT '[]', + organizer TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'confirmed', + source TEXT NOT NULL DEFAULT 'manual', + source_uid TEXT, + source_message_uid INTEGER, + source_message_folder TEXT, + meeting_url TEXT, + color TEXT, + reminder_minutes INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}calendar_settings ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + default_view TEXT NOT NULL DEFAULT 'month', + week_starts_on INTEGER NOT NULL DEFAULT 0, + working_hours_start TEXT NOT NULL DEFAULT '09:00', + working_hours_end TEXT NOT NULL DEFAULT '17:00', + time_format TEXT NOT NULL DEFAULT '12h', + zoom_link TEXT NOT NULL DEFAULT '', + teams_link TEXT NOT NULL DEFAULT '', + meet_link TEXT NOT NULL DEFAULT '', + caldav_url TEXT NOT NULL DEFAULT '', + caldav_username TEXT NOT NULL DEFAULT '', + caldav_password TEXT NOT NULL DEFAULT '', + caldav_enabled INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}contact_group_members ( + group_id TEXT NOT NULL, + contact_id TEXT NOT NULL, + PRIMARY KEY (group_id, contact_id), + FOREIGN KEY (group_id) REFERENCES {{p}}contact_groups(id) ON DELETE CASCADE, + FOREIGN KEY (contact_id) REFERENCES {{p}}contacts(id) ON DELETE CASCADE +); + +CREATE TABLE {{p}}contact_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}contact_photos ( + contact_id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + data BLOB NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (contact_id) REFERENCES {{p}}contacts(id) ON DELETE CASCADE +); + +CREATE TABLE {{p}}contacts ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + company TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + is_favorite INTEGER NOT NULL DEFAULT 0, + last_contacted TEXT, + contact_count INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'manual', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + phone TEXT NOT NULL DEFAULT '', job_title TEXT NOT NULL DEFAULT '', address TEXT NOT NULL DEFAULT '', website TEXT NOT NULL DEFAULT '', birthday TEXT NOT NULL DEFAULT '', emails_json TEXT NOT NULL DEFAULT '[]', phones_json TEXT NOT NULL DEFAULT '[]', addresses_json TEXT NOT NULL DEFAULT '[]'); + +CREATE TABLE {{p}}display_preferences ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + density TEXT NOT NULL DEFAULT 'comfortable', + theme TEXT NOT NULL DEFAULT 'system', + language TEXT NOT NULL DEFAULT 'en', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + compose_format TEXT NOT NULL DEFAULT 'html', deep_index BOOLEAN NOT NULL DEFAULT 0, animation_mode TEXT NULL, preview_pane TEXT NOT NULL DEFAULT 'right', ui_scale TEXT NOT NULL DEFAULT 'normal', color_preset TEXT NOT NULL DEFAULT 'sage', accent_color TEXT NOT NULL DEFAULT '#6f9c86', font_family TEXT NOT NULL DEFAULT 'system', font_size INTEGER NOT NULL DEFAULT 13, row_height INTEGER NOT NULL DEFAULT 58, folder_pane_width INTEGER NOT NULL DEFAULT 228, message_pane_width INTEGER NOT NULL DEFAULT 420, border_radius INTEGER NOT NULL DEFAULT 4, contrast TEXT NOT NULL DEFAULT 'normal', show_avatars INTEGER NOT NULL DEFAULT 1, show_attachment_icons INTEGER NOT NULL DEFAULT 1, show_flags INTEGER NOT NULL DEFAULT 1, reduce_motion INTEGER NOT NULL DEFAULT 0, date_format TEXT NOT NULL DEFAULT 'locale', time_format TEXT NOT NULL DEFAULT 'locale', background_color TEXT NOT NULL DEFAULT '#121513', panel_color TEXT NOT NULL DEFAULT '#181c19', text_color TEXT NOT NULL DEFAULT '#edf0ec', compose_autosave_ms INTEGER NOT NULL DEFAULT 3000, default_priority TEXT NOT NULL DEFAULT 'normal', request_mdn_default INTEGER NOT NULL DEFAULT 0, attachment_reminder INTEGER NOT NULL DEFAULT 1, icon_size INTEGER NOT NULL DEFAULT 18, toolbar_density TEXT NOT NULL DEFAULT 'normal', sidebar_item_height INTEGER NOT NULL DEFAULT 29, reader_max_width INTEGER NOT NULL DEFAULT 0, composer_width INTEGER NOT NULL DEFAULT 900, selection_color TEXT NOT NULL DEFAULT '#28352e', unread_font_weight INTEGER NOT NULL DEFAULT 730, unread_badge_style TEXT NOT NULL DEFAULT 'dot', compact_headers INTEGER NOT NULL DEFAULT 0, show_borders INTEGER NOT NULL DEFAULT 1, scrollbar_density TEXT NOT NULL DEFAULT 'thin', monochrome INTEGER NOT NULL DEFAULT 0, folder_background TEXT NOT NULL DEFAULT '', message_background TEXT NOT NULL DEFAULT '', reader_background TEXT NOT NULL DEFAULT '', compose_font_family TEXT NOT NULL DEFAULT 'system', reply_position TEXT NOT NULL DEFAULT 'above', quote_style TEXT NOT NULL DEFAULT 'standard', forward_mode TEXT NOT NULL DEFAULT 'inline', spellcheck_enabled INTEGER NOT NULL DEFAULT 1, interface_skin TEXT NOT NULL DEFAULT 'rustbox', preset_scale_unlocked INTEGER NOT NULL DEFAULT 0); + +CREATE TABLE {{p}}draft_attachments ( + id TEXT PRIMARY KEY, + draft_id TEXT NOT NULL, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size INTEGER NOT NULL, + file_path TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (draft_id) REFERENCES {{p}}drafts(id) ON DELETE CASCADE +); + +CREATE TABLE {{p}}drafts ( + id TEXT PRIMARY KEY, + to_addresses TEXT NOT NULL DEFAULT '', + cc_addresses TEXT NOT NULL DEFAULT '', + bcc_addresses TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL DEFAULT '', + text_body TEXT NOT NULL DEFAULT '', + html_body TEXT, + in_reply_to TEXT, + references_header TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + request_mdn INTEGER NOT NULL DEFAULT 0, priority TEXT NOT NULL DEFAULT 'normal', compose_format TEXT NOT NULL DEFAULT 'html', remote_folder TEXT, remote_uid INTEGER, remote_uid_validity INTEGER, pgp_mode TEXT NOT NULL DEFAULT 'none', pgp_inline INTEGER NOT NULL DEFAULT 0, pgp_attach_public_key INTEGER NOT NULL DEFAULT 0, pgp_attach_recipient_keys INTEGER NOT NULL DEFAULT 0); + +CREATE TABLE {{p}}folder_preferences ( + folder_name TEXT PRIMARY KEY REFERENCES {{p}}folders(name) ON UPDATE CASCADE ON DELETE CASCADE, + flagged_on_top INTEGER CHECK(flagged_on_top IN (0, 1) OR flagged_on_top IS NULL), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}folders ( + name TEXT PRIMARY KEY, + delimiter TEXT, + parent TEXT, + flags TEXT NOT NULL DEFAULT '', + is_subscribed INTEGER NOT NULL DEFAULT 1, + total_count INTEGER NOT NULL DEFAULT 0, + unread_count INTEGER NOT NULL DEFAULT 0, + uid_validity INTEGER NOT NULL DEFAULT 0, + highest_modseq INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + messages_updated_at TEXT); + +CREATE TABLE {{p}}identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL, + signature_html TEXT NOT NULL DEFAULT '', + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + reply_to TEXT NOT NULL DEFAULT '', bcc TEXT NOT NULL DEFAULT '', organization TEXT NOT NULL DEFAULT '', signature_text TEXT NOT NULL DEFAULT '', signature_new INTEGER NOT NULL DEFAULT 1, signature_reply INTEGER NOT NULL DEFAULT 1, signature_forward INTEGER NOT NULL DEFAULT 1, signature_position TEXT NOT NULL DEFAULT 'above', pgp_fingerprint TEXT, pgp_sign_default INTEGER NOT NULL DEFAULT 0, pgp_encrypt_default INTEGER NOT NULL DEFAULT 0, pgp_inline_default INTEGER NOT NULL DEFAULT 0); + +CREATE TABLE {{p}}known_addresses ( + email TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + PRIMARY KEY (email) +); + +CREATE TABLE {{p}}mail_preferences ( + id INTEGER PRIMARY KEY CHECK (id = 1), + messages_per_page INTEGER NOT NULL DEFAULT 50, + mark_read_delay_ms INTEGER NOT NULL DEFAULT 0, + remote_images TEXT NOT NULL DEFAULT 'ask', + preferred_body TEXT NOT NULL DEFAULT 'auto', + thread_mode TEXT NOT NULL DEFAULT 'threaded', + collapse_quotes INTEGER NOT NULL DEFAULT 1, + show_snippets INTEGER NOT NULL DEFAULT 1, + default_sort TEXT NOT NULL DEFAULT 'date_desc', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + flagged_on_top INTEGER NOT NULL DEFAULT 0, sync_imap_keywords INTEGER NOT NULL DEFAULT 0); + +CREATE TABLE {{p}}meeting_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url_template TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '', + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}message_tags ( + tag_id TEXT NOT NULL, + message_uid INTEGER NOT NULL, + message_folder TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (tag_id, message_uid, message_folder), + FOREIGN KEY (tag_id) REFERENCES {{p}}tags(id) ON DELETE CASCADE, + FOREIGN KEY (message_uid, message_folder) REFERENCES {{p}}messages(uid, folder) ON DELETE CASCADE +); + +CREATE TABLE {{p}}messages ( + uid INTEGER NOT NULL, + folder TEXT NOT NULL, + message_id TEXT, + in_reply_to TEXT, + subject TEXT NOT NULL DEFAULT '', + from_address TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT '', + to_addresses TEXT NOT NULL DEFAULT '', + cc_addresses TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL, + flags TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + has_attachments INTEGER NOT NULL DEFAULT 0, + snippet TEXT NOT NULL DEFAULT '', + body_cached INTEGER NOT NULL DEFAULT 0, + body_html TEXT, + body_text TEXT, + references_header TEXT, date_epoch INTEGER NOT NULL DEFAULT 0, attachments_json TEXT, raw_headers TEXT, reaction TEXT, email_theme INTEGER, + PRIMARY KEY (uid, folder), + FOREIGN KEY (folder) REFERENCES {{p}}folders(name) ON DELETE CASCADE +); + +CREATE TABLE {{p}}notification_preferences ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 1, + sound INTEGER NOT NULL DEFAULT 0, + folders TEXT NOT NULL DEFAULT '["INBOX"]', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + dnd_enabled INTEGER NOT NULL DEFAULT 0, dnd_start TEXT NOT NULL DEFAULT '22:00', dnd_end TEXT NOT NULL DEFAULT '07:00'); + +CREATE TABLE {{p}}pgp_keys ( + fingerprint TEXT PRIMARY KEY, + key_id TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL DEFAULT '', + public_key_armor TEXT NOT NULL, + secret_key_path TEXT, + has_secret INTEGER NOT NULL DEFAULT 0, + trust TEXT NOT NULL DEFAULT 'unknown', + source TEXT NOT NULL DEFAULT 'import', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}pgp_preferences ( + id INTEGER PRIMARY KEY CHECK (id = 1), + wkd_enabled INTEGER NOT NULL DEFAULT 1, + hkp_enabled INTEGER NOT NULL DEFAULT 0, + require_trusted_encryption INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}response_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + body TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + format TEXT NOT NULL DEFAULT 'text'); + +CREATE TABLE {{p}}saved_searches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + query_string TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}special_folders ( + role TEXT PRIMARY KEY CHECK(role IN ('sent','drafts','trash','junk','archive')), + folder_name TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL DEFAULT '#6b7280', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + imap_keyword TEXT NOT NULL DEFAULT ''); + +CREATE TABLE {{p}}trusted_senders ( + email TEXT PRIMARY KEY COLLATE NOCASE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE {{p}}user_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE INDEX {{p}}idx_calendar_events_source_uid ON {{p}}calendar_events(source_uid); + +CREATE INDEX {{p}}idx_calendar_events_time ON {{p}}calendar_events(start_time, end_time); + +CREATE INDEX {{p}}idx_cgm_contact_id ON {{p}}contact_group_members(contact_id); + +CREATE INDEX {{p}}idx_contacts_contact_count ON {{p}}contacts(contact_count DESC); + +CREATE UNIQUE INDEX {{p}}idx_contacts_email ON {{p}}contacts(email); + +CREATE INDEX {{p}}idx_contacts_last_contacted ON {{p}}contacts(last_contacted DESC); + +CREATE INDEX {{p}}idx_contacts_name ON {{p}}contacts(name); + +CREATE INDEX {{p}}idx_known_addresses_name ON {{p}}known_addresses(name); + +CREATE INDEX {{p}}idx_message_tags_message ON {{p}}message_tags(message_uid, message_folder); + +CREATE INDEX {{p}}idx_message_tags_tag ON {{p}}message_tags(tag_id); + +CREATE INDEX {{p}}idx_messages_folder_date ON {{p}}messages(folder, date_epoch DESC); + +CREATE INDEX {{p}}idx_messages_in_reply_to ON {{p}}messages(in_reply_to); + +CREATE INDEX {{p}}idx_messages_message_id ON {{p}}messages(message_id); + +CREATE INDEX {{p}}idx_pgp_keys_email ON {{p}}pgp_keys(lower(email)); + +CREATE UNIQUE INDEX {{p}}idx_saved_searches_name ON {{p}}saved_searches(name COLLATE NOCASE); + +CREATE UNIQUE INDEX {{p}}idx_tags_imap_keyword ON {{p}}tags(imap_keyword) WHERE imap_keyword <> ''; + +-- RUSTBOX:TENANT-END diff --git a/packaging/rustbox.toml b/packaging/rustbox.toml index dbc4dc2..02d7b25 100644 --- a/packaging/rustbox.toml +++ b/packaging/rustbox.toml @@ -1,4 +1,4 @@ -# RustBox 0.1.0 - packaged production configuration (DEB/RPM) +# RustBox 0.1.1 - packaged production configuration (DEB/RPM) # # The active values below intentionally keep the existing package profile. # Alternative storage/cluster examples are provided at the end of this file @@ -13,9 +13,9 @@ environment = "production" data_dir = "/var/lib/rustbox" log_dir = "/var/log/rustbox" -# Sessions and "remember me". -# With no database/session backend keys configured, RustBox defaults to -# persistent SQLite sessions under data_dir and survives process restarts. +# Database, sessions and "remember me". +# With no database/session backend keys configured, the whole application uses +# SQLite at data_dir/rustbox.sqlite; sessions are stored in that database. session_timeout_hours = 72 remember_me_enabled = true remember_me_days = 30 @@ -74,21 +74,21 @@ pgp_allow_private_export = true # Copy/uncomment only the keys required by the selected profile. # # session_encryption_key must represent the same 32-byte key on every node that -# shares a SQL/Redis session store. Keep it secret and outside source control. +# shares the configured SQL database or Redis session backend. Keep it secret and outside source control. # ----------------------------------------------------------------------------- -# Profile A: standalone SQLite sessions (default package behavior) +# Profile A: standalone SQLite application database (default package behavior) # ----------------------------------------------------------------------------- # No extra keys are required. The equivalent explicit configuration is: # database_backend = "sqlite" # session_backend = "database" # cluster_mode = false # -# RustBox stores sessions in data_dir/rustbox.sqlite and creates/uses the local -# session encryption key for this single-node SQLite setup. +# RustBox stores account data and sessions in data_dir/rustbox.sqlite and +# creates/uses the local session encryption key for this single-node setup. # ----------------------------------------------------------------------------- -# Profile B: PostgreSQL-backed persistent sessions +# Profile B: PostgreSQL application database (data + database sessions) # ----------------------------------------------------------------------------- # database_backend = "pgsql" # database_url = "postgres://rustbox:change-me@127.0.0.1/rustbox" @@ -97,7 +97,7 @@ pgp_allow_private_export = true # session_encryption_key = "BASE64_32_BYTE_KEY" # ----------------------------------------------------------------------------- -# Profile C: MySQL-backed persistent sessions +# Profile C: MySQL application database (data + database sessions; MySQL 8.0.13+) # ----------------------------------------------------------------------------- # database_backend = "mysql" # database_url = "mysql://rustbox:change-me@127.0.0.1/rustbox" @@ -106,7 +106,7 @@ pgp_allow_private_export = true # session_encryption_key = "BASE64_32_BYTE_KEY" # ----------------------------------------------------------------------------- -# Profile D: Redis sessions, single application node +# Profile D: Redis sessions, single application node (account data stays in database_backend) # ----------------------------------------------------------------------------- # session_backend = "redis" # redis_url = "redis://127.0.0.1:6379/" @@ -119,6 +119,9 @@ pgp_allow_private_export = true # ----------------------------------------------------------------------------- # Profile E: Redis Sentinel sessions for multiple RustBox nodes # ----------------------------------------------------------------------------- +# Cluster nodes must also share the main application database. +# database_backend = "pgsql" +# database_url = "postgres://rustbox:change-me@db.internal/rustbox" # session_backend = "redis" # cluster_mode = true # redis_sentinel_nodes = ["sentinel-a:26379", "sentinel-b:26379", "sentinel-c:26379"] @@ -128,7 +131,7 @@ pgp_allow_private_export = true # session_encryption_key = "BASE64_32_BYTE_KEY" # ----------------------------------------------------------------------------- -# Profile F: PostgreSQL sessions shared by a RustBox application cluster +# Profile F: PostgreSQL application database shared by a RustBox cluster # ----------------------------------------------------------------------------- # database_backend = "pgsql" # database_url = "postgres://rustbox:change-me@db.internal/rustbox" @@ -137,7 +140,7 @@ pgp_allow_private_export = true # session_encryption_key = "BASE64_32_BYTE_KEY" # ----------------------------------------------------------------------------- -# Profile G: MySQL sessions shared by a RustBox application cluster +# Profile G: MySQL application database shared by a RustBox cluster # ----------------------------------------------------------------------------- # database_backend = "mysql" # database_url = "mysql://rustbox:change-me@db.internal/rustbox" diff --git a/qar.txt b/qar.txt deleted file mode 100644 index 74efec6..0000000 --- a/qar.txt +++ /dev/null @@ -1,9 +0,0 @@ -RustBox: http://127.0.0.1:3001 -Config: /home/mg/RustBox/config.dev.toml -Data: /home/mg/RustBox/.dev/data - -RustBox configuration OK -{"timestamp":"2026-10-30T15:14:14.084058Z","level":"DEBUG","fields":{"message":"Purged expired sessions"},"target":"rustbox"} -{"timestamp":"2026-10-30T15:14:14.085996Z","level":"INFO","fields":{"message":"RustBox server starting","host":"127.0.0.1","port":"3001"},"target":"rustbox"} -{"timestamp":"2026-10-30T15:14:15.105150Z","level":"INFO","fields":{"message":"Shutdown signal received; stopping IDLE tasks"},"target":"rustbox"} -{"timestamp":"2026-10-30T15:14:15.105664Z","level":"INFO","fields":{"message":"RustBox server stopped"},"target":"rustbox"} diff --git a/scripts/finish-upstream.sh b/scripts/finish-upstream.sh index c235861..eb92237 100755 --- a/scripts/finish-upstream.sh +++ b/scripts/finish-upstream.sh @@ -6,13 +6,15 @@ cd "$ROOT" UI_REVIEWED=0 ROOT_REVIEWED=0 +OWNED_REVIEWED=0 SKIP_BUILD=0 for arg in "$@"; do case "$arg" in --ui-reviewed) UI_REVIEWED=1 ;; --root-reviewed) ROOT_REVIEWED=1 ;; + --rustbox-owned-reviewed) OWNED_REVIEWED=1 ;; --skip-build) SKIP_BUILD=1 ;; - *) echo "usage: $0 [--ui-reviewed] [--root-reviewed] [--skip-build]" >&2; exit 2 ;; + *) echo "usage: $0 [--ui-reviewed] [--root-reviewed] [--rustbox-owned-reviewed] [--skip-build]" >&2; exit 2 ;; esac done @@ -49,6 +51,15 @@ if find . -path ./.git -prune -o -type f \( -name '*.ts' -o -name '*.tsx' \) -pr exit 1 fi +if ! git diff --quiet "$BASE..$TARGET_SHA" -- backend/migrations/ backend/tests/; then + if [ "$OWNED_REVIEWED" -ne 1 ]; then + echo "error: Oxi changed backend migrations/tests that RustBox owns independently." >&2 + echo "Review/port only relevant behavior, then rerun with --rustbox-owned-reviewed." >&2 + git diff --name-status "$BASE..$TARGET_SHA" -- backend/migrations/ backend/tests/ >&2 || true + exit 1 + fi +fi + if ! git diff --quiet "$BASE..$TARGET_SHA" -- frontend/; then if [ "$UI_REVIEWED" -ne 1 ]; then echo "error: Oxi frontend changed between $BASE and $TARGET_SHA." >&2 diff --git a/scripts/oxi-port-commit.sh b/scripts/oxi-port-commit.sh index 41f93c4..e4fa729 100755 --- a/scripts/oxi-port-commit.sh +++ b/scripts/oxi-port-commit.sh @@ -34,7 +34,10 @@ tmp_mapped="$(mktemp)" cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; } trap cleanup EXIT -git diff --binary --find-renames "$PARENT..$SHA" -- backend/ > "$tmp_raw" +git diff --binary --find-renames "$PARENT..$SHA" -- \ + backend/ \ + ':(exclude)backend/migrations/**' \ + ':(exclude)backend/tests/**' > "$tmp_raw" sed \ -e 's#^diff --git a/backend/#diff --git a/#' \ -e 's# b/backend/# b/#' \ @@ -56,4 +59,5 @@ fi printf 'Ported backend portion of Oxi commit %s onto branch %s.\n' "$SHA" "$branch" printf 'UPSTREAM_BASE was intentionally NOT changed.\n' +printf 'Review excluded upstream DB/tests separately: git show %s -- backend/migrations/ backend/tests/\n' "$SHA" printf 'Review the original commit, especially frontend changes: git show %s -- frontend/\n' "$SHA" diff --git a/scripts/oxi-status.sh b/scripts/oxi-status.sh index bf4fdbb..6f6e7fa 100755 --- a/scripts/oxi-status.sh +++ b/scripts/oxi-status.sh @@ -29,8 +29,11 @@ printf ' consumed: %s\n' "$BASE" printf ' target: %s\n' "$TARGET_SHA" printf ' commits: %s\n' "$(git rev-list --count "$BASE..$TARGET_SHA")" -printf '\nOxi backend changes that can be mapped into RustBox root:\n' -git diff --name-status "$BASE..$TARGET_SHA" -- backend/ || true +printf '\nOxi backend changes that can be mapped automatically into RustBox root:\n' +git diff --name-status "$BASE..$TARGET_SHA" -- backend/ ':(exclude)backend/migrations/**' ':(exclude)backend/tests/**' || true + +printf '\nOxi backend DB migrations/tests requiring manual review (never auto-imported):\n' +git diff --name-status "$BASE..$TARGET_SHA" -- backend/migrations/ backend/tests/ || true printf '\nOxi frontend changes requiring manual RustBox UI port:\n' git diff --name-status "$BASE..$TARGET_SHA" -- frontend/ || true diff --git a/scripts/setup-upstream.sh b/scripts/setup-upstream.sh index 900f07d..bbc3694 100755 --- a/scripts/setup-upstream.sh +++ b/scripts/setup-upstream.sh @@ -12,7 +12,7 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "Initialize it first:" >&2 echo " git init" >&2 echo " git add ." >&2 - echo " git commit -m 'RustBox 0.0.1 baseline'" >&2 + echo " git commit -m 'RustBox 0.1.1 baseline'" >&2 exit 1 fi diff --git a/scripts/sync-upstream.sh b/scripts/sync-upstream.sh index 7a58494..9bb2ee8 100755 --- a/scripts/sync-upstream.sh +++ b/scripts/sync-upstream.sh @@ -45,7 +45,10 @@ tmp_mapped="$(mktemp)" cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; } trap cleanup EXIT -git diff --binary --find-renames "$BASE..$TARGET_SHA" -- backend/ > "$tmp_raw" +git diff --binary --find-renames "$BASE..$TARGET_SHA" -- \ + backend/ \ + ':(exclude)backend/migrations/**' \ + ':(exclude)backend/tests/**' > "$tmp_raw" sed \ -e 's#^diff --git a/backend/#diff --git a/#' \ -e 's# b/backend/# b/#' \ @@ -79,6 +82,8 @@ fi printf '\nMapped Oxi backend %s -> %s into RustBox on branch %s.\n' "$BASE" "$TARGET_SHA" "$branch" printf 'UPSTREAM_BASE remains at %s until the review is explicitly finished.\n' "$BASE" +printf '\nRustBox-owned upstream backend areas were NOT imported. Review them with:\n' +printf " git diff --name-status %s..%s -- backend/migrations/ backend/tests/\n" "$BASE" "$TARGET_SHA" printf '\nFrontend changes were NOT imported. Review them with:\n' printf ' git diff --name-status %s..%s -- frontend/\n' "$BASE" "$TARGET_SHA" printf ' git diff %s..%s -- frontend/\n' "$BASE" "$TARGET_SHA" diff --git a/src/auth/user_data.rs b/src/auth/user_data.rs index 22c634f..6cca7a1 100644 --- a/src/auth/user_data.rs +++ b/src/auth/user_data.rs @@ -26,9 +26,8 @@ pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result) -> rusqlite::Result { +fn row_to_event(row: &crate::db::pool::Row<'_>) -> Result { let all_day_int: i32 = row.get(6)?; Ok(CalendarEvent { id: row.get(0)?, @@ -152,7 +152,9 @@ fn row_to_event(row: &rusqlite::Row<'_>) -> rusqlite::Result { }) } -fn row_to_settings(row: &rusqlite::Row<'_>) -> rusqlite::Result { +fn row_to_settings( + row: &crate::db::pool::Row<'_>, +) -> Result { let caldav_enabled_int: i32 = row.get(10)?; Ok(CalendarSettings { default_view: row.get(0)?, @@ -170,7 +172,9 @@ fn row_to_settings(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { +fn row_to_template( + row: &crate::db::pool::Row<'_>, +) -> Result { let is_default_int: i32 = row.get(4)?; Ok(MeetingTemplate { id: row.get(0)?, @@ -187,7 +191,8 @@ pub fn list_events( start: &str, end: &str, ) -> Result, String> { - let sql = crate::rustbox_query_db_calendar_001!(); + let sql = + crate::dbq!(db_calendar::FORMAT_001).replace("{EVENT_SELECT_COLS}", EVENT_SELECT_COLS); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare list_events: {e}"))?; @@ -203,7 +208,8 @@ pub fn list_events( } pub fn list_all_events(conn: &Connection) -> Result, String> { - let sql = crate::rustbox_query_db_calendar_002!(); + let sql = + crate::dbq!(db_calendar::FORMAT_002).replace("{EVENT_SELECT_COLS}", EVENT_SELECT_COLS); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare list_all_events: {e}"))?; @@ -219,12 +225,13 @@ pub fn list_all_events(conn: &Connection) -> Result, String> } pub fn get_event(conn: &Connection, id: &str) -> Result, String> { - let sql = crate::rustbox_query_db_calendar_003!(); + let sql = + crate::dbq!(db_calendar::FORMAT_003).replace("{EVENT_SELECT_COLS}", EVENT_SELECT_COLS); let result = conn.query_row(&sql, params![id], row_to_event); match result { Ok(e) => Ok(Some(e)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get event: {e}")), } } @@ -232,15 +239,11 @@ pub fn get_event(conn: &Connection, id: &str) -> Result, S pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result { let id = Uuid::new_v4().to_string(); let now: String = conn - .query_row( - crate::queries::sqlite::queries::db_calendar::Q_001, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(db_calendar::Q_001), [], |row| row.get(0)) .map_err(|e| format!("Failed to get current time: {e}"))?; conn.execute( - crate::queries::sqlite::queries::db_calendar::Q_002, + crate::dbq!(db_calendar::Q_002), params![ id, data.title, @@ -275,14 +278,14 @@ pub fn update_event( data: &UpdateEvent, ) -> Result, String> { let mut sets = Vec::new(); - let mut values: Vec> = Vec::new(); + let mut values: Vec = Vec::new(); let mut idx = 1u32; macro_rules! maybe_set { ($field:ident, $col:expr) => { if let Some(ref v) = data.$field { sets.push(format!("{} = ?{idx}", $col)); - values.push(Box::new(v.clone())); + values.push(v.to_db_value()); idx += 1; } }; @@ -302,13 +305,13 @@ pub fn update_event( if let Some(v) = data.all_day { sets.push(format!("all_day = ?{idx}")); - values.push(Box::new(v as i32)); + values.push((v as i32).to_db_value()); idx += 1; } if let Some(v) = data.reminder_minutes { sets.push(format!("reminder_minutes = ?{idx}")); - values.push(Box::new(v)); + values.push(v.to_db_value()); idx += 1; } @@ -318,12 +321,15 @@ pub fn update_event( sets.push("updated_at = datetime('now')".to_string()); - let sql = crate::rustbox_query_db_calendar_004!(sets.join(", "), idx); - values.push(Box::new(id.to_string())); + let sql = format!( + "UPDATE calendar_events SET {} WHERE id = ?{}", + sets.join(", "), + idx + ); + values.push(id.to_db_value()); - let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|b| b.as_ref()).collect(); let updated = conn - .execute(&sql, params_refs.as_slice()) + .execute(&sql, values) .map_err(|e| format!("Failed to update event: {e}"))?; if updated == 0 { @@ -335,10 +341,7 @@ pub fn update_event( pub fn delete_event(conn: &Connection, id: &str) -> Result { let deleted = conn - .execute( - crate::queries::sqlite::queries::db_calendar::Q_003, - params![id], - ) + .execute(crate::dbq!(db_calendar::Q_003), params![id]) .map_err(|e| format!("Failed to delete event: {e}"))?; Ok(deleted > 0) } @@ -347,21 +350,23 @@ pub fn find_event_by_source_uid( conn: &Connection, source_uid: &str, ) -> Result, String> { - let sql = crate::rustbox_query_db_calendar_005!(); + let sql = + crate::dbq!(db_calendar::FORMAT_005).replace("{EVENT_SELECT_COLS}", EVENT_SELECT_COLS); let result = conn.query_row(&sql, params![source_uid], row_to_event); match result { Ok(e) => Ok(Some(e)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to find event by source_uid: {e}")), } } pub fn get_calendar_settings(conn: &Connection) -> Result { - conn.execute(crate::queries::sqlite::queries::db_calendar::Q_004, []) + conn.execute(crate::dbq!(db_calendar::Q_004), []) .map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?; - let sql = crate::rustbox_query_db_calendar_006!(); + let sql = crate::dbq!(db_calendar::FORMAT_006) + .replace("{SETTINGS_SELECT_COLS}", SETTINGS_SELECT_COLS); conn.query_row(&sql, [], row_to_settings) .map_err(|e| format!("Failed to get calendar settings: {e}")) } @@ -370,18 +375,18 @@ pub fn update_calendar_settings( conn: &Connection, data: &UpdateCalendarSettings, ) -> Result { - conn.execute(crate::queries::sqlite::queries::db_calendar::Q_005, []) + conn.execute(crate::dbq!(db_calendar::Q_005), []) .map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?; let mut sets = Vec::new(); - let mut values: Vec> = Vec::new(); + let mut values: Vec = Vec::new(); let mut idx = 1u32; macro_rules! maybe_set { ($field:ident, $col:expr) => { if let Some(ref v) = data.$field { sets.push(format!("{} = ?{idx}", $col)); - values.push(Box::new(v.clone())); + values.push(v.to_db_value()); idx += 1; } }; @@ -400,13 +405,13 @@ pub fn update_calendar_settings( if let Some(v) = data.week_starts_on { sets.push(format!("week_starts_on = ?{idx}")); - values.push(Box::new(v)); + values.push(v.to_db_value()); idx += 1; } if let Some(v) = data.caldav_enabled { sets.push(format!("caldav_enabled = ?{idx}")); - values.push(Box::new(v as i32)); + values.push((v as i32).to_db_value()); #[allow(unused_assignments)] { idx += 1; @@ -415,10 +420,11 @@ pub fn update_calendar_settings( if !sets.is_empty() { sets.push("updated_at = datetime('now')".to_string()); - let sql = crate::rustbox_query_db_calendar_007!(sets.join(", ")); - let params_refs: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|b| b.as_ref()).collect(); - conn.execute(&sql, params_refs.as_slice()) + let sql = format!( + "UPDATE calendar_settings SET {} WHERE id = 1", + sets.join(", ") + ); + conn.execute(&sql, values) .map_err(|e| format!("Failed to update calendar settings: {e}"))?; } @@ -427,7 +433,7 @@ pub fn update_calendar_settings( pub fn list_meeting_templates(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_calendar::Q_006) + .prepare(crate::dbq!(db_calendar::Q_006)) .map_err(|e| format!("Failed to prepare list_meeting_templates: {e}"))?; let rows = stmt .query_map([], row_to_template) @@ -446,24 +452,21 @@ pub fn create_meeting_template( url_template: &str, icon: &str, ) -> Result { - conn.execute( - crate::queries::sqlite::queries::db_calendar::Q_007, - params![name, url_template, icon], - ) - .map_err(|e| format!("Failed to create meeting template: {e}"))?; + let id = conn + .insert_id( + crate::dbq!(db_calendar::Q_007), + params![name, url_template, icon], + ) + .map_err(|e| format!("Failed to create meeting template: {e}"))?; - let id = conn.last_insert_rowid(); - let sql = crate::queries::sqlite::queries::db_calendar::Q_008; + let sql = crate::dbq!(db_calendar::Q_008); conn.query_row(sql, params![id], row_to_template) .map_err(|e| format!("Failed to read created template: {e}")) } pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result { let deleted = conn - .execute( - crate::queries::sqlite::queries::db_calendar::Q_009, - params![id], - ) + .execute(crate::dbq!(db_calendar::Q_009), params![id]) .map_err(|e| format!("Failed to delete meeting template: {e}"))?; Ok(deleted > 0) } diff --git a/src/db/contact_groups.rs b/src/db/contact_groups.rs index ba6d4a7..c3aa9fa 100644 --- a/src/db/contact_groups.rs +++ b/src/db/contact_groups.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, params}; +use crate::db::pool::{Connection, params}; use serde::Serialize; use crate::db::contacts::Contact; @@ -13,17 +13,14 @@ pub struct ContactGroup { } pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_contact_groups::Q_001, - params![id, name], - ) - .map_err(|e| format!("Failed to create contact group: {e}"))?; + conn.execute(crate::dbq!(db_contact_groups::Q_001), params![id, name]) + .map_err(|e| format!("Failed to create contact group: {e}"))?; Ok(()) } pub fn list_groups(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_contact_groups::Q_002) + .prepare(crate::dbq!(db_contact_groups::Q_002)) .map_err(|e| format!("Failed to prepare list_groups: {e}"))?; let rows = stmt @@ -47,27 +44,21 @@ pub fn list_groups(conn: &Connection) -> Result, String> { pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result { let updated = conn - .execute( - crate::queries::sqlite::queries::db_contact_groups::Q_003, - params![name, id], - ) + .execute(crate::dbq!(db_contact_groups::Q_003), params![name, id]) .map_err(|e| format!("Failed to update contact group: {e}"))?; Ok(updated > 0) } pub fn delete_group(conn: &Connection, id: &str) -> Result { let deleted = conn - .execute( - crate::queries::sqlite::queries::db_contact_groups::Q_004, - params![id], - ) + .execute(crate::dbq!(db_contact_groups::Q_004), params![id]) .map_err(|e| format!("Failed to delete contact group: {e}"))?; Ok(deleted > 0) } pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_contact_groups::Q_005, + crate::dbq!(db_contact_groups::Q_005), params![group_id, contact_id], ) .map_err(|e| format!("Failed to add group member: {e}"))?; @@ -77,7 +68,7 @@ pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result { let deleted = conn .execute( - crate::queries::sqlite::queries::db_contact_groups::Q_006, + crate::dbq!(db_contact_groups::Q_006), params![group_id, contact_id], ) .map_err(|e| format!("Failed to remove group member: {e}"))?; @@ -86,7 +77,7 @@ pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Res pub fn list_group_members(conn: &Connection, group_id: &str) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_contact_groups::Q_007) + .prepare(crate::dbq!(db_contact_groups::Q_007)) .map_err(|e| format!("Failed to prepare list_group_members: {e}"))?; let rows = stmt @@ -131,7 +122,7 @@ pub fn list_contact_groups( contact_id: &str, ) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_contact_groups::Q_008) + .prepare(crate::dbq!(db_contact_groups::Q_008)) .map_err(|e| format!("Failed to prepare list_contact_groups: {e}"))?; let rows = stmt diff --git a/src/db/contacts.rs b/src/db/contacts.rs index 5dcd2e6..9f0cd70 100644 --- a/src/db/contacts.rs +++ b/src/db/contacts.rs @@ -1,5 +1,5 @@ +use crate::db::pool::{Connection, params}; use crate::queries::sqlite::queries::db_contacts::CONTACT_SELECT_COLS; -use rusqlite::{Connection, params}; use serde::Serialize; use uuid::Uuid; @@ -33,7 +33,7 @@ pub struct Contact { pub has_photo: bool, } -fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result { +fn row_to_contact(row: &crate::db::pool::Row<'_>) -> Result { Ok(Contact { id: row.get(0)?, email: row.get(1)?, @@ -60,14 +60,14 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result { } fn escape_like(term: &str) -> String { - term.replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_") + term.replace('!', "!!") + .replace('%', "!%") + .replace('_', "!_") } pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_contacts::Q_001, + crate::dbq!(db_contacts::Q_001), params![ contact.id, contact.email, @@ -95,23 +95,25 @@ pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String } pub fn get_contact(conn: &Connection, id: &str) -> Result, String> { - let sql = crate::rustbox_query_db_contacts_001!(); + let sql = + crate::dbq!(db_contacts::FORMAT_001).replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); let result = conn.query_row(&sql, params![id], row_to_contact); match result { Ok(c) => Ok(Some(c)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get contact: {e}")), } } pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result, String> { - let sql = crate::rustbox_query_db_contacts_002!(); + let sql = + crate::dbq!(db_contacts::FORMAT_002).replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); let result = conn.query_row(&sql, params![email], row_to_contact); match result { Ok(c) => Ok(Some(c)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get contact by email: {e}")), } } @@ -126,13 +128,15 @@ pub fn list_contacts( Some(q) if !q.is_empty() => { let escaped = escape_like(q); let pattern = format!("%{escaped}%"); - let sql = crate::rustbox_query_db_contacts_003!(); + let sql = crate::dbq!(db_contacts::FORMAT_003) + .replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); drop(pattern); (sql, Some(escaped)) } _ => { - let sql = crate::rustbox_query_db_contacts_004!(); + let sql = crate::dbq!(db_contacts::FORMAT_004) + .replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); (sql, None) } }; @@ -170,10 +174,7 @@ pub fn list_contacts( pub fn delete_contact(conn: &Connection, id: &str) -> Result { let deleted = conn - .execute( - crate::queries::sqlite::queries::db_contacts::Q_002, - params![id], - ) + .execute(crate::dbq!(db_contacts::Q_002), params![id]) .map_err(|e| format!("Failed to delete contact: {e}"))?; Ok(deleted > 0) } @@ -182,7 +183,8 @@ pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result Result<(), String> { if !from_address.is_empty() { conn.execute( - crate::queries::sqlite::queries::db_contacts::Q_004, + crate::dbq!(db_contacts::Q_004), params![from_address, from_name], ) .map_err(|e| format!("Failed to insert known address (from): {e}"))?; @@ -262,11 +264,8 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin continue; } let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); - conn.execute( - crate::queries::sqlite::queries::db_contacts::Q_005, - params![email, name], - ) - .map_err(|e| format!("Failed to insert known address (json): {e}"))?; + conn.execute(crate::dbq!(db_contacts::Q_005), params![email, name]) + .map_err(|e| format!("Failed to insert known address (json): {e}"))?; } Ok(()) @@ -274,22 +273,16 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin #[allow(dead_code)] pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_contacts::Q_006, - params![email], - ) - .map_err(|e| format!("Failed to increment contact count: {e}"))?; + conn.execute(crate::dbq!(db_contacts::Q_006), params![email]) + .map_err(|e| format!("Failed to increment contact count: {e}"))?; Ok(()) } #[allow(dead_code)] pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> { let id = Uuid::new_v4().to_string(); - conn.execute( - crate::queries::sqlite::queries::db_contacts::Q_007, - params![id, email, name], - ) - .map_err(|e| format!("Failed to auto-add contact: {e}"))?; + conn.execute(crate::dbq!(db_contacts::Q_007), params![id, email, name]) + .map_err(|e| format!("Failed to auto-add contact: {e}"))?; Ok(()) } @@ -300,7 +293,7 @@ pub fn set_contact_photo( data: &[u8], ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_contacts::Q_008, + crate::dbq!(db_contacts::Q_008), params![contact_id, content_type, data], ) .map_err(|e| format!("Failed to save contact photo: {e}"))?; @@ -312,23 +305,20 @@ pub fn get_contact_photo( contact_id: &str, ) -> Result)>, String> { match conn.query_row( - crate::queries::sqlite::queries::db_contacts::Q_009, + crate::dbq!(db_contacts::Q_009), params![contact_id], |row| Ok((row.get(0)?, row.get(1)?)), ) { Ok(value) => Ok(Some(value)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to load contact photo: {e}")), } } pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result { - conn.execute( - crate::queries::sqlite::queries::db_contacts::Q_010, - params![contact_id], - ) - .map(|n| n > 0) - .map_err(|e| format!("Failed to delete contact photo: {e}")) + conn.execute(crate::dbq!(db_contacts::Q_010), params![contact_id]) + .map(|n| n > 0) + .map_err(|e| format!("Failed to delete contact photo: {e}")) } #[cfg(test)] diff --git a/src/db/display_preferences.rs b/src/db/display_preferences.rs index 9fdee08..d8c60a8 100644 --- a/src/db/display_preferences.rs +++ b/src/db/display_preferences.rs @@ -1,4 +1,4 @@ -use rusqlite::Connection; +use crate::db::pool::{Connection, DbValue, ToDbValue}; use serde::{Deserialize, Deserializer, Serialize}; #[derive(Debug, Clone, Serialize)] @@ -184,70 +184,66 @@ fn defaults() -> DisplayPreferences { } pub fn get_preferences(conn: &Connection) -> Result { - let result = conn.query_row( - crate::queries::sqlite::queries::db_display_preferences::Q_001, - [], - |row| { - Ok(DisplayPreferences { - density: row.get(0)?, - theme: row.get(1)?, - language: row.get(2)?, - compose_format: row.get(3)?, - deep_index: row.get::<_, i32>(4)? != 0, - animation_mode: row.get(5)?, - preview_pane: row.get(6)?, - ui_scale: row.get(7)?, - color_preset: row.get(8)?, - accent_color: row.get(9)?, - background_color: row.get(10)?, - panel_color: row.get(11)?, - text_color: row.get(12)?, - font_family: row.get(13)?, - font_size: row.get(14)?, - row_height: row.get(15)?, - folder_pane_width: row.get(16)?, - message_pane_width: row.get(17)?, - border_radius: row.get(18)?, - contrast: row.get(19)?, - show_avatars: row.get::<_, i32>(20)? != 0, - show_attachment_icons: row.get::<_, i32>(21)? != 0, - show_flags: row.get::<_, i32>(22)? != 0, - reduce_motion: row.get::<_, i32>(23)? != 0, - date_format: row.get(24)?, - time_format: row.get(25)?, - compose_autosave_ms: row.get(26)?, - default_priority: row.get(27)?, - request_mdn_default: row.get::<_, i32>(28)? != 0, - attachment_reminder: row.get::<_, i32>(29)? != 0, - icon_size: row.get(30)?, - toolbar_density: row.get(31)?, - sidebar_item_height: row.get(32)?, - reader_max_width: row.get(33)?, - composer_width: row.get(34)?, - selection_color: row.get(35)?, - unread_font_weight: row.get(36)?, - unread_badge_style: row.get(37)?, - compact_headers: row.get::<_, i32>(38)? != 0, - show_borders: row.get::<_, i32>(39)? != 0, - scrollbar_density: row.get(40)?, - monochrome: row.get::<_, i32>(41)? != 0, - folder_background: row.get(42)?, - message_background: row.get(43)?, - reader_background: row.get(44)?, - compose_font_family: row.get(45)?, - reply_position: row.get(46)?, - quote_style: row.get(47)?, - forward_mode: row.get(48)?, - spellcheck_enabled: row.get::<_, i32>(49)? != 0, - interface_skin: row.get(50)?, - preset_scale_unlocked: row.get::<_, i32>(51)? != 0, - updated_at: row.get(52)?, - }) - }, - ); + let result = conn.query_row(crate::dbq!(db_display_preferences::Q_001), [], |row| { + Ok(DisplayPreferences { + density: row.get(0)?, + theme: row.get(1)?, + language: row.get(2)?, + compose_format: row.get(3)?, + deep_index: row.get::<_, i32>(4)? != 0, + animation_mode: row.get(5)?, + preview_pane: row.get(6)?, + ui_scale: row.get(7)?, + color_preset: row.get(8)?, + accent_color: row.get(9)?, + background_color: row.get(10)?, + panel_color: row.get(11)?, + text_color: row.get(12)?, + font_family: row.get(13)?, + font_size: row.get(14)?, + row_height: row.get(15)?, + folder_pane_width: row.get(16)?, + message_pane_width: row.get(17)?, + border_radius: row.get(18)?, + contrast: row.get(19)?, + show_avatars: row.get::<_, i32>(20)? != 0, + show_attachment_icons: row.get::<_, i32>(21)? != 0, + show_flags: row.get::<_, i32>(22)? != 0, + reduce_motion: row.get::<_, i32>(23)? != 0, + date_format: row.get(24)?, + time_format: row.get(25)?, + compose_autosave_ms: row.get(26)?, + default_priority: row.get(27)?, + request_mdn_default: row.get::<_, i32>(28)? != 0, + attachment_reminder: row.get::<_, i32>(29)? != 0, + icon_size: row.get(30)?, + toolbar_density: row.get(31)?, + sidebar_item_height: row.get(32)?, + reader_max_width: row.get(33)?, + composer_width: row.get(34)?, + selection_color: row.get(35)?, + unread_font_weight: row.get(36)?, + unread_badge_style: row.get(37)?, + compact_headers: row.get::<_, i32>(38)? != 0, + show_borders: row.get::<_, i32>(39)? != 0, + scrollbar_density: row.get(40)?, + monochrome: row.get::<_, i32>(41)? != 0, + folder_background: row.get(42)?, + message_background: row.get(43)?, + reader_background: row.get(44)?, + compose_font_family: row.get(45)?, + reply_position: row.get(46)?, + quote_style: row.get(47)?, + forward_mode: row.get(48)?, + spellcheck_enabled: row.get::<_, i32>(49)? != 0, + interface_skin: row.get(50)?, + preset_scale_unlocked: row.get::<_, i32>(51)? != 0, + updated_at: row.get(52)?, + }) + }); match result { Ok(value) => Ok(value), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(defaults()), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(defaults()), Err(error) => Err(format!("Failed to get display preferences: {error}")), } } @@ -274,20 +270,17 @@ pub fn update_preferences( conn: &Connection, data: &UpdateDisplayPreferences, ) -> Result { - conn.execute( - crate::queries::sqlite::queries::db_display_preferences::Q_002, - [], - ) - .map_err(|e| format!("Failed to ensure preferences row: {e}"))?; + conn.execute(crate::dbq!(db_display_preferences::Q_002), []) + .map_err(|e| format!("Failed to ensure preferences row: {e}"))?; let mut sets = Vec::new(); - let mut values: Vec> = Vec::new(); + let mut values: Vec = Vec::new(); let mut idx = 1usize; macro_rules! set_text { ($field:ident, $column:literal) => { if let Some(ref value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); - values.push(Box::new(value.clone())); + values.push(value.to_db_value()); idx += 1; } }; @@ -296,7 +289,7 @@ pub fn update_preferences( ($field:ident, $column:literal) => { if let Some(value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); - values.push(Box::new(value)); + values.push(value.to_db_value()); idx += 1; } }; @@ -305,7 +298,7 @@ pub fn update_preferences( ($field:ident, $column:literal) => { if let Some(value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); - values.push(Box::new(value as i32)); + values.push((value as i32).to_db_value()); idx += 1; } }; @@ -499,7 +492,7 @@ pub fn update_preferences( set_bool!(deep_index, "deep_index"); if let Some(ref value) = data.animation_mode { sets.push(format!("animation_mode = ?{}", idx)); - values.push(Box::new(value.clone())); + values.push(value.to_db_value()); idx += 1; } set_text!(preview_pane, "preview_pane"); @@ -549,10 +542,13 @@ pub fn update_preferences( return get_preferences(conn); } sets.push("updated_at = datetime('now')".to_string()); - let sql = crate::rustbox_query_db_display_preferences_001!(sets.join(", "), idx); - values.push(Box::new(1_i32)); - let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect(); - conn.execute(&sql, refs.as_slice()) + let sql = format!( + "UPDATE display_preferences SET {} WHERE id = ?{}", + sets.join(", "), + idx + ); + values.push(1_i32.to_db_value()); + conn.execute(&sql, values) .map_err(|e| format!("Failed to update display preferences: {e}"))?; get_preferences(conn) } diff --git a/src/db/drafts.rs b/src/db/drafts.rs index c8ff13b..dd867b0 100644 --- a/src/db/drafts.rs +++ b/src/db/drafts.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, params}; +use crate::db::pool::{Connection, params}; use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -72,7 +72,7 @@ pub fn upsert_draft( ) -> Result<(), String> { validate_draft_id(id)?; conn.execute( - crate::queries::sqlite::queries::db_drafts::Q_001, + crate::dbq!(db_drafts::Q_001), params![ id, to_addresses, @@ -97,46 +97,42 @@ pub fn upsert_draft( } pub fn get_draft(conn: &Connection, id: &str) -> Result, String> { - let result = conn.query_row( - crate::queries::sqlite::queries::db_drafts::Q_002, - params![id], - |row| { - Ok(Draft { - id: row.get(0)?, - to_addresses: row.get(1)?, - cc_addresses: row.get(2)?, - bcc_addresses: row.get(3)?, - subject: row.get(4)?, - text_body: row.get(5)?, - html_body: row.get(6)?, - in_reply_to: row.get(7)?, - references_header: row.get(8)?, - request_mdn: row.get::<_, i64>(9)? != 0, - priority: row.get(10)?, - compose_format: row.get(11)?, - pgp_mode: row.get(12)?, - pgp_inline: row.get::<_, i64>(13)? != 0, - pgp_attach_public_key: row.get::<_, i64>(14)? != 0, - pgp_attach_recipient_keys: row.get::<_, i64>(15)? != 0, - remote_folder: row.get(16)?, - remote_uid: row.get(17)?, - remote_uid_validity: row.get(18)?, - created_at: row.get(19)?, - updated_at: row.get(20)?, - }) - }, - ); + let result = conn.query_row(crate::dbq!(db_drafts::Q_002), params![id], |row| { + Ok(Draft { + id: row.get(0)?, + to_addresses: row.get(1)?, + cc_addresses: row.get(2)?, + bcc_addresses: row.get(3)?, + subject: row.get(4)?, + text_body: row.get(5)?, + html_body: row.get(6)?, + in_reply_to: row.get(7)?, + references_header: row.get(8)?, + request_mdn: row.get::<_, i64>(9)? != 0, + priority: row.get(10)?, + compose_format: row.get(11)?, + pgp_mode: row.get(12)?, + pgp_inline: row.get::<_, i64>(13)? != 0, + pgp_attach_public_key: row.get::<_, i64>(14)? != 0, + pgp_attach_recipient_keys: row.get::<_, i64>(15)? != 0, + remote_folder: row.get(16)?, + remote_uid: row.get(17)?, + remote_uid_validity: row.get(18)?, + created_at: row.get(19)?, + updated_at: row.get(20)?, + }) + }); match result { Ok(draft) => Ok(Some(draft)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get draft: {e}")), } } pub fn list_drafts(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_drafts::Q_003) + .prepare(crate::dbq!(db_drafts::Q_003)) .map_err(|e| format!("Failed to prepare list_drafts: {e}"))?; let rows = stmt @@ -183,7 +179,7 @@ pub fn set_remote_location( ) -> Result<(), String> { let updated = conn .execute( - crate::queries::sqlite::queries::db_drafts::Q_004, + crate::dbq!(db_drafts::Q_004), params![id, folder, uid, uid_validity], ) .map_err(|e| format!("Failed to update remote draft location: {e}"))?; @@ -195,10 +191,7 @@ pub fn set_remote_location( pub fn delete_draft(conn: &Connection, id: &str) -> Result { let deleted = conn - .execute( - crate::queries::sqlite::queries::db_drafts::Q_005, - params![id], - ) + .execute(crate::dbq!(db_drafts::Q_005), params![id]) .map_err(|e| format!("Failed to delete draft: {e}"))?; Ok(deleted > 0) } @@ -213,7 +206,7 @@ pub fn add_draft_attachment( file_path: &str, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_drafts::Q_006, + crate::dbq!(db_drafts::Q_006), params![id, draft_id, filename, content_type, size, file_path], ) .map_err(|e| format!("Failed to add draft attachment: {e}"))?; @@ -225,7 +218,7 @@ pub fn get_draft_attachments( draft_id: &str, ) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_drafts::Q_007) + .prepare(crate::dbq!(db_drafts::Q_007)) .map_err(|e| format!("Failed to prepare get_draft_attachments: {e}"))?; let rows = stmt @@ -251,10 +244,7 @@ pub fn get_draft_attachments( pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result { let deleted = conn - .execute( - crate::queries::sqlite::queries::db_drafts::Q_008, - params![id], - ) + .execute(crate::dbq!(db_drafts::Q_008), params![id]) .map_err(|e| format!("Failed to delete draft attachment: {e}"))?; Ok(deleted > 0) } diff --git a/src/db/folder_preferences.rs b/src/db/folder_preferences.rs index ac5b198..e6d6c8f 100644 --- a/src/db/folder_preferences.rs +++ b/src/db/folder_preferences.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, OptionalExtension, params}; +use crate::db::pool::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize)] @@ -13,11 +13,9 @@ pub struct UpdateFolderPreferences { } fn get_override(conn: &Connection, folder: &str) -> Result, String> { - conn.query_row( - crate::queries::sqlite::queries::db_folder_preferences::Q_001, - [folder], - |row| row.get::<_, Option>(0), - ) + conn.query_row(crate::dbq!(db_folder_preferences::Q_001), [folder], |row| { + row.get::<_, Option>(0) + }) .optional() .map(|value| value.flatten().map(|value| value != 0)) .map_err(|error| format!("Failed to read folder preferences: {error}")) @@ -28,33 +26,27 @@ pub fn effective_flagged_on_top(conn: &Connection, folder: &str) -> Result(0), - ) + .query_row(crate::dbq!(db_folder_preferences::Q_002), [], |row| { + row.get::<_, i32>(0) + }) .unwrap_or(0); Ok(global != 0) } pub fn get_preferences(conn: &Connection, folder: &str) -> Result { let exists: bool = conn - .query_row( - crate::queries::sqlite::queries::db_folder_preferences::Q_003, - [folder], - |row| row.get(0), - ) + .query_row(crate::dbq!(db_folder_preferences::Q_003), [folder], |row| { + row.get(0) + }) .map_err(|error| format!("Failed to validate folder: {error}"))?; if !exists { return Err(format!("Folder does not exist: {folder}")); } let flagged_on_top = get_override(conn, folder)?; let effective_flagged_on_top = flagged_on_top.unwrap_or( - conn.query_row( - crate::queries::sqlite::queries::db_folder_preferences::Q_004, - [], - |row| row.get::<_, i32>(0), - ) + conn.query_row(crate::dbq!(db_folder_preferences::Q_004), [], |row| { + row.get::<_, i32>(0) + }) .unwrap_or(0) != 0, ); @@ -70,11 +62,9 @@ pub fn update_preferences( data: &UpdateFolderPreferences, ) -> Result { let exists: bool = conn - .query_row( - crate::queries::sqlite::queries::db_folder_preferences::Q_005, - [folder], - |row| row.get(0), - ) + .query_row(crate::dbq!(db_folder_preferences::Q_005), [folder], |row| { + row.get(0) + }) .map_err(|error| format!("Failed to validate folder: {error}"))?; if !exists { return Err(format!("Folder does not exist: {folder}")); @@ -83,17 +73,14 @@ pub fn update_preferences( match data.flagged_on_top { Some(value) => { conn.execute( - crate::queries::sqlite::queries::db_folder_preferences::Q_006, + crate::dbq!(db_folder_preferences::Q_006), params![folder, value as i32], ) .map_err(|error| format!("Failed to update folder preferences: {error}"))?; } None => { - conn.execute( - crate::queries::sqlite::queries::db_folder_preferences::Q_007, - [folder], - ) - .map_err(|error| format!("Failed to clear folder preferences: {error}"))?; + conn.execute(crate::dbq!(db_folder_preferences::Q_007), [folder]) + .map_err(|error| format!("Failed to clear folder preferences: {error}"))?; } } diff --git a/src/db/folders.rs b/src/db/folders.rs index a890781..4a70187 100644 --- a/src/db/folders.rs +++ b/src/db/folders.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, OptionalExtension, params}; +use crate::db::pool::{Connection, DbValue, OptionalExtension, ToDbValue, params}; use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -28,7 +28,7 @@ pub fn upsert_folder( highest_modseq: u64, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_folders::Q_001, + crate::dbq!(db_folders::Q_001), params![ folder_name, delimiter, @@ -52,7 +52,7 @@ pub fn insert_folder_if_new( flags_csv: &str, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_folders::Q_002, + crate::dbq!(db_folders::Q_002), params![folder_name, delimiter, flags_csv], ) .map_err(|e| format!("Failed to insert folder: {e}"))?; @@ -61,7 +61,7 @@ pub fn insert_folder_if_new( pub fn get_all_folders(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_folders::Q_003) + .prepare(crate::dbq!(db_folders::Q_003)) .map_err(|e| format!("Failed to prepare get_all_folders: {e}"))?; let rows = stmt @@ -92,28 +92,28 @@ pub fn get_all_folders(conn: &Connection) -> Result, String> { pub fn remove_stale_folders(conn: &Connection, current_names: &[String]) -> Result { if current_names.is_empty() { let deleted = conn - .execute(crate::queries::sqlite::queries::db_folders::Q_004, []) + .execute(crate::dbq!(db_folders::Q_004), []) .map_err(|e| format!("Failed to delete all folders: {e}"))?; return Ok(deleted); } let placeholders: Vec = (1..=current_names.len()).map(|i| format!("?{i}")).collect(); - let sql = crate::rustbox_query_db_folders_001!(placeholders.join(", ")); + let sql = format!( + "DELETE FROM folders WHERE name NOT IN ({})", + placeholders.join(", ") + ); - let params: Vec<&dyn rusqlite::types::ToSql> = current_names - .iter() - .map(|n| n as &dyn rusqlite::types::ToSql) - .collect(); + let params: Vec = current_names.iter().map(ToDbValue::to_db_value).collect(); let deleted = conn - .execute(&sql, params.as_slice()) + .execute(&sql, params) .map_err(|e| format!("Failed to remove stale folders: {e}"))?; Ok(deleted) } pub fn touch_all_folders(conn: &Connection) -> Result<(), String> { - conn.execute(crate::queries::sqlite::queries::db_folders::Q_005, []) + conn.execute(crate::dbq!(db_folders::Q_005), []) .map_err(|e| format!("Failed to touch folders: {e}"))?; Ok(()) } @@ -125,7 +125,7 @@ pub fn update_folder_status( total_count: u32, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_folders::Q_006, + crate::dbq!(db_folders::Q_006), params![uid_validity, total_count, name], ) .map_err(|e| format!("Failed to update folder status: {e}"))?; @@ -140,7 +140,7 @@ pub fn update_folder_sync_status( highest_modseq: u64, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_folders::Q_007, + crate::dbq!(db_folders::Q_007), params![uid_validity, total_count, highest_modseq as i64, name], ) .map_err(|e| format!("Failed to update folder sync status: {e}"))?; @@ -150,35 +150,29 @@ pub fn update_folder_sync_status( pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> { let unread: u32 = conn .query_row( - crate::queries::sqlite::queries::db_folders::Q_008, + crate::dbq!(db_folders::Q_008), params![folder_name], |row| row.get(0), ) .map_err(|e| format!("Failed to count unread messages: {e}"))?; - conn.execute( - crate::queries::sqlite::queries::db_folders::Q_009, - params![unread, folder_name], - ) - .map_err(|e| format!("Failed to update unread_count: {e}"))?; + conn.execute(crate::dbq!(db_folders::Q_009), params![unread, folder_name]) + .map_err(|e| format!("Failed to update unread_count: {e}"))?; Ok(()) } pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_folders::Q_010, - params![delta, folder_name], - ) - .map_err(|e| format!("Failed to adjust unread_count: {e}"))?; + conn.execute(crate::dbq!(db_folders::Q_010), params![delta, folder_name]) + .map_err(|e| format!("Failed to adjust unread_count: {e}"))?; Ok(()) } pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result { let fresh: bool = conn .query_row( - crate::queries::sqlite::queries::db_folders::Q_011, - params![format!("-{max_age_secs} seconds")], + crate::dbq!(db_folders::Q_011), + params![max_age_secs], |row| row.get(0), ) .map_err(|e| format!("Failed to check folder cache freshness: {e}"))?; @@ -192,8 +186,8 @@ pub fn is_folder_fresh( ) -> Result { let fresh: bool = conn .query_row( - crate::queries::sqlite::queries::db_folders::Q_012, - params![folder_name, format!("-{max_age_secs} seconds")], + crate::dbq!(db_folders::Q_012), + params![folder_name, max_age_secs], |row| row.get(0), ) .map_err(|e| format!("Failed to check folder freshness: {e}"))?; @@ -206,7 +200,7 @@ pub fn is_folder_messages_invalidated( ) -> Result { let invalidated: bool = conn .query_row( - crate::queries::sqlite::queries::db_folders::Q_013, + crate::dbq!(db_folders::Q_013), params![folder_name], |row| row.get(0), ) @@ -215,53 +209,40 @@ pub fn is_folder_messages_invalidated( } pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_folders::Q_014, - params![folder_name], - ) - .map_err(|e| format!("Failed to invalidate folder freshness: {e}"))?; + conn.execute(crate::dbq!(db_folders::Q_014), params![folder_name]) + .map_err(|e| format!("Failed to invalidate folder freshness: {e}"))?; Ok(()) } pub fn get_folder(conn: &Connection, name: &str) -> Result, String> { - let result = conn.query_row( - crate::queries::sqlite::queries::db_folders::Q_015, - params![name], - |row| { - let is_subscribed_int: i32 = row.get(4)?; - let highest_modseq_int: i64 = row.get(8)?; - Ok(CachedFolder { - name: row.get(0)?, - delimiter: row.get(1)?, - parent: row.get(2)?, - flags: row.get(3)?, - is_subscribed: is_subscribed_int != 0, - total_count: row.get(5)?, - unread_count: row.get(6)?, - uid_validity: row.get(7)?, - highest_modseq: highest_modseq_int as u64, - }) - }, - ); + let result = conn.query_row(crate::dbq!(db_folders::Q_015), params![name], |row| { + let is_subscribed_int: i32 = row.get(4)?; + let highest_modseq_int: i64 = row.get(8)?; + Ok(CachedFolder { + name: row.get(0)?, + delimiter: row.get(1)?, + parent: row.get(2)?, + flags: row.get(3)?, + is_subscribed: is_subscribed_int != 0, + total_count: row.get(5)?, + unread_count: row.get(6)?, + uid_validity: row.get(7)?, + highest_modseq: highest_modseq_int as u64, + }) + }); match result { Ok(folder) => Ok(Some(folder)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get folder: {e}")), } } pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_folders::Q_016, - params![folder_name], - ) - .map_err(|e| format!("Failed to delete folder messages: {e}"))?; - conn.execute( - crate::queries::sqlite::queries::db_folders::Q_017, - params![folder_name], - ) - .map_err(|e| format!("Failed to delete folder: {e}"))?; + conn.execute(crate::dbq!(db_folders::Q_016), params![folder_name]) + .map_err(|e| format!("Failed to delete folder messages: {e}"))?; + conn.execute(crate::dbq!(db_folders::Q_017), params![folder_name]) + .map_err(|e| format!("Failed to delete folder: {e}"))?; Ok(()) } @@ -273,44 +254,26 @@ pub fn rename_folder_in_cache( let tx = conn .unchecked_transaction() .map_err(|e| format!("Failed to start folder rename transaction: {e}"))?; - tx.execute_batch(crate::queries::sqlite::queries::db_folders::Q_018) + tx.execute_batch(crate::dbq!(db_folders::Q_018)) .map_err(|e| format!("Failed to defer folder rename foreign keys: {e}"))?; let inserted = tx - .execute( - crate::queries::sqlite::queries::db_folders::Q_019, - params![new_name, old_name], - ) + .execute(crate::dbq!(db_folders::Q_019), params![new_name, old_name]) .map_err(|e| format!("Failed to create renamed folder cache entry: {e}"))?; if inserted == 0 { return Err(format!("Folder not found in cache: {old_name}")); } - tx.execute( - crate::queries::sqlite::queries::db_folders::Q_020, - params![new_name, old_name], - ) - .map_err(|e| format!("Failed to rename messages folder: {e}"))?; - tx.execute( - crate::queries::sqlite::queries::db_folders::Q_021, - params![new_name, old_name], - ) - .map_err(|e| format!("Failed to rename message tag folder references: {e}"))?; - tx.execute( - crate::queries::sqlite::queries::db_folders::Q_022, - params![new_name, old_name], - ) - .ok(); - tx.execute( - crate::queries::sqlite::queries::db_folders::Q_023, - params![new_name, old_name], - ) - .map_err(|e| format!("Failed to update child folder parent: {e}"))?; - tx.execute( - crate::queries::sqlite::queries::db_folders::Q_024, - params![old_name], - ) - .map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?; + tx.execute(crate::dbq!(db_folders::Q_020), params![new_name, old_name]) + .map_err(|e| format!("Failed to rename messages folder: {e}"))?; + tx.execute(crate::dbq!(db_folders::Q_021), params![new_name, old_name]) + .map_err(|e| format!("Failed to rename message tag folder references: {e}"))?; + tx.execute(crate::dbq!(db_folders::Q_022), params![new_name, old_name]) + .ok(); + tx.execute(crate::dbq!(db_folders::Q_023), params![new_name, old_name]) + .map_err(|e| format!("Failed to update child folder parent: {e}"))?; + tx.execute(crate::dbq!(db_folders::Q_024), params![old_name]) + .map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?; tx.commit() .map_err(|e| format!("Failed to commit folder rename: {e}"))?; Ok(()) @@ -320,11 +283,9 @@ pub fn find_folder_by_attribute( conn: &Connection, attribute: &str, ) -> Result, String> { - conn.query_row( - crate::queries::sqlite::queries::db_folders::Q_025, - [attribute], - |row| row.get(0), - ) + conn.query_row(crate::dbq!(db_folders::Q_025), [attribute], |row| { + row.get(0) + }) .optional() .map_err(|e| e.to_string()) } @@ -442,17 +403,12 @@ mod tests { fn sync_metadata_preserves_message_sync_timestamp() { let conn = open_test_db(); insert_folder_if_new(&conn, "Sent", None, "\\Sent").unwrap(); - conn.execute(crate::queries::sqlite::queries::db_folders::Q_026, []) - .unwrap(); + conn.execute(crate::dbq!(db_folders::Q_026), []).unwrap(); update_folder_sync_status(&conn, "Sent", 1, 5, 42).unwrap(); let timestamp: String = conn - .query_row( - crate::queries::sqlite::queries::db_folders::Q_027, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(db_folders::Q_027), [], |row| row.get(0)) .unwrap(); assert_eq!(timestamp, "2000-01-01 00:00:00"); assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap()); diff --git a/src/db/header_sync.rs b/src/db/header_sync.rs index 56c7aaa..f5dd725 100644 --- a/src/db/header_sync.rs +++ b/src/db/header_sync.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior}; +use crate::db::pool::{Connection, OptionalExtension, Transaction}; #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) struct HeaderSync { @@ -8,11 +8,9 @@ pub(crate) struct HeaderSync { impl HeaderSync { pub(crate) fn capture(conn: &Connection) -> Result { let generation = conn - .query_row( - crate::queries::sqlite::queries::db_header_sync::Q_001, - [], - |row| row.get::<_, String>(0), - ) + .query_row(crate::dbq!(db_header_sync::Q_001), [], |row| { + row.get::<_, String>(0) + }) .optional() .map_err(|e| e.to_string())? .map(|value| value.parse::().map_err(|e| e.to_string())) @@ -21,18 +19,17 @@ impl HeaderSync { Ok(Self { generation }) } - pub(crate) fn begin_write(self, conn: &Connection) -> Result, String> { - let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate) - .map_err(|e| e.to_string())?; - if Self::capture(conn)?.generation != self.generation { + pub(crate) fn begin_write(self, conn: &Connection) -> Result { + let transaction = conn.unchecked_transaction().map_err(|e| e.to_string())?; + if Self::capture(&transaction)?.generation != self.generation { return Err("Header sync superseded by cache removal; retry on next sync".into()); } Ok(transaction) } } -pub(crate) fn invalidate(conn: &Transaction<'_>) -> Result<(), String> { - conn.execute(crate::queries::sqlite::queries::db_header_sync::Q_002, []) +pub(crate) fn invalidate(conn: &Transaction) -> Result<(), String> { + conn.execute(crate::dbq!(db_header_sync::Q_002), []) .map_err(|e| e.to_string())?; Ok(()) } diff --git a/src/db/identities.rs b/src/db/identities.rs index c3d591b..707f42f 100644 --- a/src/db/identities.rs +++ b/src/db/identities.rs @@ -1,5 +1,5 @@ +use crate::db::pool::{Connection, DbValue, ToDbValue, params}; use crate::queries::sqlite::queries::db_identities::IDENTITY_SELECT_COLS; -use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize)] @@ -95,7 +95,7 @@ fn validate_signature_position(value: &str) -> Result<(), String> { } } -fn row_to_identity(row: &rusqlite::Row<'_>) -> rusqlite::Result { +fn row_to_identity(row: &crate::db::pool::Row<'_>) -> Result { Ok(Identity { id: row.get(0)?, display_name: row.get(1)?, @@ -120,7 +120,8 @@ fn row_to_identity(row: &rusqlite::Row<'_>) -> rusqlite::Result { } pub fn list_identities(conn: &Connection) -> Result, String> { - let sql = crate::rustbox_query_db_identities_001!(); + let sql = crate::dbq!(db_identities::FORMAT_001) + .replace("{IDENTITY_SELECT_COLS}", IDENTITY_SELECT_COLS); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare list_identities: {e}"))?; @@ -132,20 +133,22 @@ pub fn list_identities(conn: &Connection) -> Result, String> { } pub fn get_identity(conn: &Connection, id: i64) -> Result, String> { - let sql = crate::rustbox_query_db_identities_002!(); + let sql = crate::dbq!(db_identities::FORMAT_002) + .replace("{IDENTITY_SELECT_COLS}", IDENTITY_SELECT_COLS); match conn.query_row(&sql, params![id], row_to_identity) { Ok(identity) => Ok(Some(identity)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get identity: {e}")), } } #[allow(dead_code)] pub fn get_default_identity(conn: &Connection) -> Result, String> { - let sql = crate::rustbox_query_db_identities_003!(); + let sql = crate::dbq!(db_identities::FORMAT_003) + .replace("{IDENTITY_SELECT_COLS}", IDENTITY_SELECT_COLS); match conn.query_row(&sql, [], row_to_identity) { Ok(identity) => Ok(Some(identity)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get default identity: {e}")), } } @@ -155,29 +158,29 @@ pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result> = Vec::new(); + let mut values: Vec = Vec::new(); let mut idx = 1usize; macro_rules! text { ($field:ident,$column:literal) => { if let Some(ref value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); - values.push(Box::new(value.clone())); + values.push(value.to_db_value()); idx += 1; } }; @@ -211,7 +214,7 @@ pub fn update_identity( ($field:ident,$column:literal) => { if let Some(value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); - values.push(Box::new(value as i32)); + values.push((value as i32).to_db_value()); idx += 1; } }; @@ -230,7 +233,7 @@ pub fn update_identity( text!(signature_position, "signature_position"); if let Some(ref value) = data.pgp_fingerprint { sets.push(format!("pgp_fingerprint = ?{}", idx)); - values.push(Box::new(value.clone())); + values.push(value.to_db_value()); idx += 1; } boolean!(pgp_sign_default, "pgp_sign_default"); @@ -240,38 +243,33 @@ pub fn update_identity( return get_identity(conn, id); } sets.push("updated_at = datetime('now')".to_string()); - let sql = crate::rustbox_query_db_identities_004!(sets.join(", "), idx); - values.push(Box::new(id)); - let refs: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|value| value.as_ref()).collect(); - conn.execute(&sql, refs.as_slice()) + let sql = format!( + "UPDATE identities SET {} WHERE id = ?{}", + sets.join(", "), + idx + ); + values.push(id.to_db_value()); + conn.execute(&sql, values) .map_err(|e| format!("Failed to update identity: {e}"))?; get_identity(conn, id) } pub fn delete_identity(conn: &Connection, id: i64) -> Result { Ok(conn - .execute( - crate::queries::sqlite::queries::db_identities::Q_002, - params![id], - ) + .execute(crate::dbq!(db_identities::Q_002), params![id]) .map_err(|e| format!("Failed to delete identity: {e}"))? > 0) } pub fn has_identities(conn: &Connection) -> Result { let count: i64 = conn - .query_row( - crate::queries::sqlite::queries::db_identities::Q_003, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(db_identities::Q_003), [], |row| row.get(0)) .map_err(|e| format!("Failed to count identities: {e}"))?; Ok(count > 0) } fn clear_default(conn: &Connection) -> Result<(), String> { - conn.execute(crate::queries::sqlite::queries::db_identities::Q_004, []) + conn.execute(crate::dbq!(db_identities::Q_004), []) .map_err(|e| format!("Failed to clear default identity: {e}"))?; Ok(()) } diff --git a/src/db/mail_preferences.rs b/src/db/mail_preferences.rs index 324cf58..d48bcc0 100644 --- a/src/db/mail_preferences.rs +++ b/src/db/mail_preferences.rs @@ -1,4 +1,4 @@ -use rusqlite::Connection; +use crate::db::pool::{Connection, params}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize)] @@ -31,25 +31,21 @@ pub struct UpdateMailPreferences { } pub fn get_preferences(conn: &Connection) -> Result { - conn.query_row( - crate::queries::sqlite::queries::db_mail_preferences::Q_001, - [], - |row| { - Ok(MailPreferences { - messages_per_page: row.get::<_, u32>(0)?, - mark_read_delay_ms: row.get::<_, u64>(1)?, - remote_images: row.get(2)?, - preferred_body: row.get(3)?, - thread_mode: row.get(4)?, - collapse_quotes: row.get::<_, i32>(5)? != 0, - show_snippets: row.get::<_, i32>(6)? != 0, - default_sort: row.get(7)?, - flagged_on_top: row.get::<_, i32>(8)? != 0, - sync_imap_keywords: row.get::<_, i32>(9)? != 0, - updated_at: row.get(10)?, - }) - }, - ) + conn.query_row(crate::dbq!(db_mail_preferences::Q_001), [], |row| { + Ok(MailPreferences { + messages_per_page: row.get::<_, u32>(0)?, + mark_read_delay_ms: row.get::<_, u64>(1)?, + remote_images: row.get(2)?, + preferred_body: row.get(3)?, + thread_mode: row.get(4)?, + collapse_quotes: row.get::<_, i32>(5)? != 0, + show_snippets: row.get::<_, i32>(6)? != 0, + default_sort: row.get(7)?, + flagged_on_top: row.get::<_, i32>(8)? != 0, + sync_imap_keywords: row.get::<_, i32>(9)? != 0, + updated_at: row.get(10)?, + }) + }) .map_err(|error| format!("Failed to read mail preferences: {error}")) } @@ -57,11 +53,8 @@ pub fn update_preferences( conn: &Connection, data: &UpdateMailPreferences, ) -> Result { - conn.execute( - crate::queries::sqlite::queries::db_mail_preferences::Q_002, - [], - ) - .map_err(|error| format!("Failed to initialize mail preferences: {error}"))?; + conn.execute(crate::dbq!(db_mail_preferences::Q_002), []) + .map_err(|error| format!("Failed to initialize mail preferences: {error}"))?; if let Some(value) = data.messages_per_page && !(20..=200).contains(&value) @@ -130,8 +123,8 @@ pub fn update_preferences( .unwrap_or(current.sync_imap_keywords); conn.execute( - crate::queries::sqlite::queries::db_mail_preferences::Q_003, - rusqlite::params![ + crate::dbq!(db_mail_preferences::Q_003), + params![ messages_per_page, mark_read_delay_ms, remote_images, diff --git a/src/db/messages.rs b/src/db/messages.rs index b7401dc..b1e53d4 100644 --- a/src/db/messages.rs +++ b/src/db/messages.rs @@ -1,5 +1,7 @@ +use crate::db::pool::{ + Connection, DatabaseBackend, DbValue, ToDbValue, configured_backend, params, +}; use crate::queries::sqlite::queries::db_messages::MSG_SELECT_COLS; -use rusqlite::{Connection, params}; use serde::Serialize; #[derive(Debug, Clone)] @@ -35,7 +37,9 @@ pub struct CachedMessage { pub reaction: Option, } -fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result { +fn row_to_cached_message( + row: &crate::db::pool::Row<'_>, +) -> Result { let has_attachments_int: i32 = row.get(13)?; Ok(CachedMessage { uid: row.get(0)?, @@ -79,7 +83,7 @@ pub fn upsert_message( ) -> Result<(), String> { let date_epoch = parse_date_to_epoch(date); conn.execute( - crate::queries::sqlite::queries::db_messages::Q_001, + crate::dbq!(db_messages::Q_001), params![ uid, folder, @@ -105,37 +109,30 @@ pub fn upsert_message( } pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> { - let transaction = - rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) - .map_err(|e| e.to_string())?; - delete_message(conn, folder, uid)?; + let transaction = conn.unchecked_transaction().map_err(|e| e.to_string())?; + delete_message(&transaction, folder, uid)?; super::header_sync::invalidate(&transaction)?; - super::folders::invalidate_folder_freshness(conn, folder)?; + super::folders::invalidate_folder_freshness(&transaction, folder)?; transaction.commit().map_err(|e| e.to_string()) } pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result { let deleted = conn - .execute( - crate::queries::sqlite::queries::db_messages::Q_002, - params![folder], - ) + .execute(crate::dbq!(db_messages::Q_002), params![folder]) .map_err(|e| format!("Failed to delete folder messages: {e}"))?; Ok(deleted) } pub fn max_uid(conn: &Connection, folder: &str) -> Result { - conn.query_row( - crate::queries::sqlite::queries::db_messages::Q_003, - params![folder], - |row| row.get(0), - ) + conn.query_row(crate::dbq!(db_messages::Q_003), params![folder], |row| { + row.get(0) + }) .map_err(|e| format!("Failed to get max uid: {e}")) } pub fn list_index_source_messages(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_messages::Q_004) + .prepare(crate::dbq!(db_messages::Q_004)) .map_err(|e| format!("Failed to prepare index rebuild query: {e}"))?; let rows = stmt @@ -253,7 +250,7 @@ pub fn get_messages( per_page: u32, ) -> Result, String> { let offset = page * per_page; - let sql = crate::rustbox_query_db_messages_001!(); + let sql = crate::dbq!(db_messages::FORMAT_001).replace("{MSG_SELECT_COLS}", MSG_SELECT_COLS); let mut stmt = conn .prepare(&sql) @@ -298,13 +295,20 @@ pub fn get_messages_sorted( _ => return Err(format!("Unsupported message sort: {sort}")), }; let order_by = if flagged_on_top { - format!("CASE WHEN instr(flags, '\\Flagged') > 0 THEN 0 ELSE 1 END ASC, {base_order}") + let flagged_test = match configured_backend() { + DatabaseBackend::Mysql => "LOCATE(CONCAT(CHAR(92), 'Flagged'), flags) > 0", + DatabaseBackend::Pgsql => "strpos(flags, '\\Flagged') > 0", + DatabaseBackend::Sqlite => "instr(flags, '\\Flagged') > 0", + }; + format!("CASE WHEN {flagged_test} THEN 0 ELSE 1 END ASC, {base_order}") } else { base_order.to_string() }; let offset = page * per_page; - let sql = crate::rustbox_query_db_messages_002!(&order_by); + let sql = crate::dbq!(db_messages::FORMAT_002) + .replace("{MSG_SELECT_COLS}", MSG_SELECT_COLS) + .replace("{order_by}", &order_by); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare sorted get_messages: {e}"))?; @@ -316,21 +320,15 @@ pub fn get_messages_sorted( } pub fn count_messages(conn: &Connection, folder: &str) -> Result { - conn.query_row( - crate::queries::sqlite::queries::db_messages::Q_005, - params![folder], - |row| row.get(0), - ) + conn.query_row(crate::dbq!(db_messages::Q_005), params![folder], |row| { + row.get(0) + }) .map_err(|e| format!("Failed to count messages: {e}")) } pub fn count_all_messages(conn: &Connection) -> Result { let count: i64 = conn - .query_row( - crate::queries::sqlite::queries::db_messages::Q_006, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(db_messages::Q_006), [], |row| row.get(0)) .map_err(|e| format!("Failed to count cached messages: {e}"))?; usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string()) } @@ -342,7 +340,7 @@ pub fn update_message_flags( flags_csv: &str, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_messages::Q_007, + crate::dbq!(db_messages::Q_007), params![flags_csv, folder, uid], ) .map_err(|e| format!("Failed to update message flags: {e}"))?; @@ -361,7 +359,7 @@ pub fn cache_message_body( email_theme: Option, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_messages::Q_008, + crate::dbq!(db_messages::Q_008), params![ html, text, @@ -383,7 +381,7 @@ pub fn update_email_theme( email_theme: i32, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_messages::Q_009, + crate::dbq!(db_messages::Q_009), params![email_theme, folder, uid], ) .map_err(|e| format!("Failed to update email theme: {e}"))?; @@ -404,7 +402,7 @@ pub fn get_cached_body( uid: u32, ) -> Result, String> { let result = conn.query_row( - crate::queries::sqlite::queries::db_messages::Q_010, + crate::dbq!(db_messages::Q_010), params![folder, uid], |row| { let cached: i32 = row.get(0)?; @@ -424,7 +422,7 @@ pub fn get_cached_body( match result { Ok(body) => Ok(body), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get cached body: {e}")), } } @@ -434,29 +432,26 @@ pub fn get_single_message( folder: &str, uid: u32, ) -> Result, String> { - let sql = crate::rustbox_query_db_messages_003!(); + let sql = crate::dbq!(db_messages::FORMAT_003).replace("{MSG_SELECT_COLS}", MSG_SELECT_COLS); let result = conn.query_row(&sql, params![folder, uid], row_to_cached_message); match result { Ok(msg) => Ok(Some(msg)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get single message: {e}")), } } pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_messages::Q_011, - params![folder, uid], - ) - .map_err(|e| format!("Failed to delete message: {e}"))?; + conn.execute(crate::dbq!(db_messages::Q_011), params![folder, uid]) + .map_err(|e| format!("Failed to delete message: {e}"))?; Ok(()) } pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_messages::Q_012) + .prepare(crate::dbq!(db_messages::Q_012)) .map_err(|e| format!("Failed to prepare get_unindexed_messages: {e}"))?; let rows = stmt @@ -477,7 +472,7 @@ pub fn get_all_uids_and_flags( folder: &str, ) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_messages::Q_013) + .prepare(crate::dbq!(db_messages::Q_013)) .map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?; let rows = stmt .query_map(params![folder], |row| { @@ -496,9 +491,12 @@ pub fn get_thread_messages( conn: &Connection, target_message_id: &str, ) -> Result, String> { - let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_"); + let escaped_target = target_message_id + .replace('!', "!!") + .replace("%", "!%") + .replace("_", "!_"); let like_pattern = format!("%{}%", escaped_target); - let sql = crate::rustbox_query_db_messages_004!(); + let sql = crate::dbq!(db_messages::FORMAT_004).replace("{MSG_SELECT_COLS}", MSG_SELECT_COLS); let mut stmt = conn .prepare(&sql) @@ -606,7 +604,7 @@ pub fn search_messages_sqlite( limit: usize, ) -> Result, String> { let mut conditions = Vec::new(); - let mut param_values: Vec> = Vec::new(); + let mut param_values: Vec = Vec::new(); let mut idx = 1u32; conditions.push("LOWER(folder) NOT IN ('trash', 'spam', 'junk')".to_string()); @@ -616,19 +614,19 @@ pub fn search_messages_sqlite( conditions.push(format!( "(subject LIKE ?{idx} OR from_name LIKE ?{idx} OR from_address LIKE ?{idx} OR to_addresses LIKE ?{idx})" )); - param_values.push(Box::new(pattern)); + param_values.push(pattern.to_db_value()); idx += 1; } if let Some(subject) = subject.filter(|value| !value.trim().is_empty()) { conditions.push(format!("subject LIKE ?{idx}")); - param_values.push(Box::new(format!("%{subject}%"))); + param_values.push(format!("%{subject}%").to_db_value()); idx += 1; } if let Some(f) = folder { conditions.push(format!("folder = ?{idx}")); - param_values.push(Box::new(f.to_string())); + param_values.push(f.to_db_value()); idx += 1; } @@ -643,47 +641,57 @@ pub fn search_messages_sqlite( } else { format!("%{f}%") }; - param_values.push(Box::new(val)); + param_values.push(val.to_db_value()); idx += 1; } if let Some(t) = to { conditions.push(format!("to_addresses LIKE ?{idx}")); - param_values.push(Box::new(format!("%{t}%"))); + param_values.push(format!("%{t}%").to_db_value()); idx += 1; } if let Some(df) = date_from { conditions.push(format!("date_epoch >= ?{idx}")); - param_values.push(Box::new(df)); + param_values.push(df.to_db_value()); idx += 1; } if let Some(dt) = date_to { conditions.push(format!("date_epoch <= ?{idx}")); - param_values.push(Box::new(dt)); + param_values.push(dt.to_db_value()); idx += 1; } if let Some(ha) = has_attachment { conditions.push(format!("has_attachments = ?{idx}")); - param_values.push(Box::new(ha as i32)); + param_values.push((ha as i32).to_db_value()); idx += 1; } if let Some(want_unread) = unread { + let seen = match configured_backend() { + DatabaseBackend::Mysql => "LOCATE(CONCAT(CHAR(92), 'Seen'), flags)", + DatabaseBackend::Pgsql => "strpos(flags, '\\Seen')", + DatabaseBackend::Sqlite => "instr(flags, '\\Seen')", + }; conditions.push(if want_unread { - "flags NOT LIKE '%\\Seen%'".to_string() + format!("{seen} = 0") } else { - "flags LIKE '%\\Seen%'".to_string() + format!("{seen} > 0") }); } if let Some(want_flagged) = flagged { + let flagged = match configured_backend() { + DatabaseBackend::Mysql => "LOCATE(CONCAT(CHAR(92), 'Flagged'), flags)", + DatabaseBackend::Pgsql => "strpos(flags, '\\Flagged')", + DatabaseBackend::Sqlite => "instr(flags, '\\Flagged')", + }; conditions.push(if want_flagged { - "flags LIKE '%\\Flagged%'".to_string() + format!("{flagged} > 0") } else { - "flags NOT LIKE '%\\Flagged%'".to_string() + format!("{flagged} = 0") }); } @@ -695,21 +703,19 @@ pub fn search_messages_sqlite( format!("WHERE {}", conditions.join(" AND ")) }; - let sql = crate::rustbox_query_db_messages_005!(&where_clause, param_values.len() + 1); + let sql = format!( + "SELECT {MSG_SELECT_COLS} FROM messages {where_clause} ORDER BY date_epoch DESC LIMIT ?{}", + param_values.len() + 1 + ); - param_values.push(Box::new(limit as i64)); - - let params_refs: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|b| b.as_ref()).collect(); + param_values.push((limit as i64).to_db_value()); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare search query: {e}"))?; let rows = stmt - .query_map(rusqlite::params_from_iter(params_refs), |row| { - row_to_cached_message(row) - }) + .query_map(param_values, row_to_cached_message) .map_err(|e| format!("Failed to execute search query: {e}"))?; let mut results = Vec::new(); @@ -720,12 +726,8 @@ pub fn search_messages_sqlite( } pub fn cached_storage_bytes(conn: &Connection) -> Result, String> { - conn.query_row( - crate::queries::sqlite::queries::db_messages::Q_014, - [], - |row| row.get(0), - ) - .map_err(|e| e.to_string()) + conn.query_row(crate::dbq!(db_messages::Q_014), [], |row| row.get(0)) + .map_err(|e| e.to_string()) } #[cfg(test)] diff --git a/src/db/notification_preferences.rs b/src/db/notification_preferences.rs index f23780b..ffa0b74 100644 --- a/src/db/notification_preferences.rs +++ b/src/db/notification_preferences.rs @@ -1,4 +1,4 @@ -use rusqlite::Connection; +use crate::db::pool::{Connection, DbValue, ToDbValue}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize)] @@ -23,28 +23,24 @@ pub struct UpdateNotificationPreferences { } pub fn get_preferences(conn: &Connection) -> Result { - let result = conn.query_row( - crate::queries::sqlite::queries::db_notification_preferences::Q_001, - [], - |row| { - let enabled_int: i32 = row.get(0)?; - let sound_int: i32 = row.get(1)?; - let folders_json: String = row.get(2)?; - let dnd_enabled_int: i32 = row.get(3)?; - let dnd_start: String = row.get(4)?; - let dnd_end: String = row.get(5)?; - let updated_at: String = row.get(6)?; - Ok(( - enabled_int, - sound_int, - folders_json, - dnd_enabled_int, - dnd_start, - dnd_end, - updated_at, - )) - }, - ); + let result = conn.query_row(crate::dbq!(db_notification_preferences::Q_001), [], |row| { + let enabled_int: i32 = row.get(0)?; + let sound_int: i32 = row.get(1)?; + let folders_json: String = row.get(2)?; + let dnd_enabled_int: i32 = row.get(3)?; + let dnd_start: String = row.get(4)?; + let dnd_end: String = row.get(5)?; + let updated_at: String = row.get(6)?; + Ok(( + enabled_int, + sound_int, + folders_json, + dnd_enabled_int, + dnd_start, + dnd_end, + updated_at, + )) + }); match result { Ok(( @@ -68,7 +64,7 @@ pub fn get_preferences(conn: &Connection) -> Result Ok(NotificationPreferences { + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(NotificationPreferences { enabled: true, sound: false, folders: vec!["INBOX".to_string()], @@ -85,36 +81,33 @@ pub fn update_preferences( conn: &Connection, data: &UpdateNotificationPreferences, ) -> Result { - conn.execute( - crate::queries::sqlite::queries::db_notification_preferences::Q_002, - [], - ) - .map_err(|e| format!("Failed to ensure preferences row: {e}"))?; + conn.execute(crate::dbq!(db_notification_preferences::Q_002), []) + .map_err(|e| format!("Failed to ensure preferences row: {e}"))?; let mut sets = Vec::new(); - let mut values: Vec> = Vec::new(); + let mut values: Vec = Vec::new(); let mut idx = 1; if let Some(enabled) = data.enabled { sets.push(format!("enabled = ?{idx}")); - values.push(Box::new(enabled as i32)); + values.push((enabled as i32).to_db_value()); idx += 1; } if let Some(sound) = data.sound { sets.push(format!("sound = ?{idx}")); - values.push(Box::new(sound as i32)); + values.push((sound as i32).to_db_value()); idx += 1; } if let Some(ref folders) = data.folders { let folders_json = serde_json::to_string(folders) .map_err(|e| format!("Failed to serialize folders: {e}"))?; sets.push(format!("folders = ?{idx}")); - values.push(Box::new(folders_json)); + values.push(folders_json.to_db_value()); idx += 1; } if let Some(dnd_enabled) = data.dnd_enabled { sets.push(format!("dnd_enabled = ?{idx}")); - values.push(Box::new(dnd_enabled as i32)); + values.push((dnd_enabled as i32).to_db_value()); idx += 1; } if let Some(ref dnd_start) = data.dnd_start { @@ -122,7 +115,7 @@ pub fn update_preferences( return Err("Invalid DND start time".to_string()); } sets.push(format!("dnd_start = ?{idx}")); - values.push(Box::new(dnd_start.clone())); + values.push(dnd_start.to_db_value()); idx += 1; } if let Some(ref dnd_end) = data.dnd_end { @@ -130,7 +123,7 @@ pub fn update_preferences( return Err("Invalid DND end time".to_string()); } sets.push(format!("dnd_end = ?{idx}")); - values.push(Box::new(dnd_end.clone())); + values.push(dnd_end.to_db_value()); idx += 1; } @@ -140,12 +133,10 @@ pub fn update_preferences( sets.push("updated_at = datetime('now')".to_string()); let set_clause = sets.join(", "); - let sql = crate::rustbox_query_db_notification_preferences_001!(&set_clause, idx); - values.push(Box::new(1_i32)); + let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}"); + values.push(1_i32.to_db_value()); - let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect(); - - conn.execute(&sql, params_refs.as_slice()) + conn.execute(&sql, values) .map_err(|e| format!("Failed to update notification preferences: {e}"))?; get_preferences(conn) diff --git a/src/db/pgp.rs b/src/db/pgp.rs index 7f1c321..70b2daa 100644 --- a/src/db/pgp.rs +++ b/src/db/pgp.rs @@ -1,5 +1,5 @@ +use crate::db::pool::{Connection, params}; use crate::queries::sqlite::queries::db_pgp::KEY_COLS; -use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize)] @@ -31,7 +31,7 @@ pub struct PgpPreferences { pub require_trusted_encryption: bool, } -fn row_to_key(row: &rusqlite::Row<'_>) -> rusqlite::Result { +fn row_to_key(row: &crate::db::pool::Row<'_>) -> Result { Ok(PgpKeyInternal { record: PgpKeyRecord { fingerprint: row.get(0)?, @@ -51,7 +51,7 @@ fn row_to_key(row: &rusqlite::Row<'_>) -> rusqlite::Result { pub fn list_keys(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(&crate::rustbox_query_db_pgp_001!()) + .prepare(&crate::dbq!(db_pgp::FORMAT_001).replace("{KEY_COLS}", KEY_COLS)) .map_err(|e| format!("Failed to prepare PGP key list: {e}"))?; let rows = stmt .query_map([], row_to_key) @@ -65,17 +65,17 @@ pub fn list_keys(conn: &Connection) -> Result, String> { pub fn get_key(conn: &Connection, fingerprint: &str) -> Result, String> { let normalized = normalize_fingerprint(fingerprint); - let sql = crate::rustbox_query_db_pgp_002!(); + let sql = crate::dbq!(db_pgp::FORMAT_002).replace("{KEY_COLS}", KEY_COLS); match conn.query_row(&sql, params![normalized], row_to_key) { Ok(v) => Ok(Some(v)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get PGP key: {e}")), } } pub fn find_keys_by_email(conn: &Connection, email: &str) -> Result, String> { let mut stmt = conn - .prepare(&crate::rustbox_query_db_pgp_003!()) + .prepare(&crate::dbq!(db_pgp::FORMAT_003).replace("{KEY_COLS}", KEY_COLS)) .map_err(|e| format!("Failed to prepare PGP email lookup: {e}"))?; let rows = stmt .query_map(params![email.trim()], row_to_key) @@ -93,7 +93,7 @@ pub fn upsert_key( secret_key_path: Option<&str>, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_pgp::Q_001, + crate::dbq!(db_pgp::Q_001), params![ record.fingerprint, record.key_id, @@ -113,7 +113,7 @@ pub fn upsert_key( pub fn delete_key(conn: &Connection, fingerprint: &str) -> Result { Ok(conn .execute( - crate::queries::sqlite::queries::db_pgp::Q_002, + crate::dbq!(db_pgp::Q_002), params![normalize_fingerprint(fingerprint)], ) .map_err(|e| format!("Failed to delete PGP key: {e}"))? @@ -126,7 +126,7 @@ pub fn set_trust(conn: &Connection, fingerprint: &str, trust: &str) -> Result Result Result { - conn.query_row(crate::queries::sqlite::queries::db_pgp::Q_004, [], |row| { + conn.query_row(crate::dbq!(db_pgp::Q_004), [], |row| { Ok(PgpPreferences { wkd_enabled: row.get::<_, i32>(0)? != 0, hkp_enabled: row.get::<_, i32>(1)? != 0, @@ -149,7 +149,7 @@ pub fn update_preferences( prefs: &PgpPreferences, ) -> Result { conn.execute( - crate::queries::sqlite::queries::db_pgp::Q_005, + crate::dbq!(db_pgp::Q_005), params![ prefs.wkd_enabled as i32, prefs.hkp_enabled as i32, @@ -172,17 +172,17 @@ pub fn find_key_internal_by_email( conn: &Connection, email: &str, ) -> Result, String> { - let sql = crate::rustbox_query_db_pgp_004!(); + let sql = crate::dbq!(db_pgp::FORMAT_004).replace("{KEY_COLS}", KEY_COLS); match conn.query_row(&sql, params![email.trim()], row_to_key) { Ok(value) => Ok(Some(value)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to query PGP key by email: {e}")), } } pub fn list_secret_keys(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(&crate::rustbox_query_db_pgp_005!()) + .prepare(&crate::dbq!(db_pgp::FORMAT_005).replace("{KEY_COLS}", KEY_COLS)) .map_err(|e| format!("Failed to prepare PGP secret key list: {e}"))?; let rows = stmt .query_map([], row_to_key) diff --git a/src/db/pool.rs b/src/db/pool.rs index d4e90fd..a045ff7 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -1,14 +1,2185 @@ +use std::fmt; use std::fs; -use std::path::Path; +use std::future::Future; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock, mpsc}; -use rusqlite::Connection; +use rusqlite::OptionalExtension as _; +use rusqlite::types::{ToSql, ToSqlOutput, Value}; +use sha2::{Digest, Sha256}; +use sqlx::pool::PoolConnection; +use sqlx::{MySql, MySqlPool, PgPool, Postgres, Row as SqlxRow}; +use tokio::runtime::{Builder, Handle}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::config::AppConfig; + +pub const BASE_SCHEMA_VERSION: u32 = 46; +const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql"); +const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql"); +const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql"); + +// Future migrations start at V047. Each entry must exist for every supported engine. +const SQLITE_MIGRATIONS: &[(u32, &str)] = &[]; +const MYSQL_MIGRATIONS: &[(u32, &str)] = &[]; +const PGSQL_MIGRATIONS: &[(u32, &str)] = &[]; + +const TENANT_TABLES: &[&str] = &[ + "calendar_events", + "calendar_settings", + "contact_group_members", + "contact_groups", + "contact_photos", + "contacts", + "display_preferences", + "draft_attachments", + "drafts", + "folder_preferences", + "folders", + "identities", + "known_addresses", + "mail_preferences", + "meeting_templates", + "message_tags", + "messages", + "notification_preferences", + "pgp_keys", + "pgp_preferences", + "response_templates", + "saved_searches", + "special_folders", + "tags", + "trusted_senders", + "user_meta", +]; + +// Parent tables precede children so legacy SQLite data can be copied with foreign keys enabled. +const IMPORT_TABLES: &[&str] = &[ + "calendar_events", + "calendar_settings", + "contact_groups", + "contacts", + "contact_group_members", + "contact_photos", + "display_preferences", + "drafts", + "draft_attachments", + "folders", + "folder_preferences", + "identities", + "known_addresses", + "mail_preferences", + "meeting_templates", + "messages", + "tags", + "message_tags", + "notification_preferences", + "pgp_keys", + "pgp_preferences", + "response_templates", + "saved_searches", + "special_folders", + "trusted_senders", + "user_meta", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DatabaseBackend { + Sqlite, + Mysql, + Pgsql, +} + +impl DatabaseBackend { + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "sqlite" => Ok(Self::Sqlite), + "mysql" => Ok(Self::Mysql), + "pgsql" | "postgres" | "postgresql" => Ok(Self::Pgsql), + other => Err(format!("unsupported database backend: {other}")), + } + } +} + +#[derive(Clone)] +enum ConfiguredDatabase { + Sqlite { path: PathBuf }, + Mysql { pool: MySqlPool, runtime: Handle }, + Pgsql { pool: PgPool, runtime: Handle }, +} + +static DATABASE: OnceLock = OnceLock::new(); +static DB_RUNTIME: OnceLock = OnceLock::new(); + +fn database_runtime() -> Result { + if let Some(handle) = DB_RUNTIME.get() { + return Ok(handle.clone()); + } + + let (tx, rx) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("rustbox-database-runtime".to_string()) + .spawn(move || { + let runtime = Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("failed to build RustBox database runtime"); + let handle = runtime.handle().clone(); + tx.send(handle).expect("database runtime receiver dropped"); + runtime.block_on(std::future::pending::<()>()); + }) + .map_err(|e| format!("failed to start database runtime: {e}"))?; + + let handle = rx + .recv() + .map_err(|e| format!("failed to receive database runtime handle: {e}"))?; + let _ = DB_RUNTIME.set(handle.clone()); + Ok(handle) +} + +fn runtime_wait(runtime: &Handle, future: F) -> Result +where + T: Send + 'static, + F: Future> + Send + 'static, +{ + let (tx, rx) = mpsc::sync_channel(1); + runtime.spawn(async move { + let _ = tx.send(future.await); + }); + rx.recv() + .map_err(|e| format!("database runtime stopped unexpectedly: {e}"))? +} + +fn schema_section(schema: &str, begin: &str, end: &str) -> Result { + let start = schema + .find(begin) + .ok_or_else(|| format!("schema marker missing: {begin}"))? + + begin.len(); + let tail = &schema[start..]; + let finish = tail + .find(end) + .ok_or_else(|| format!("schema marker missing: {end}"))?; + Ok(tail[..finish].trim().to_string()) +} + +fn global_schema(schema: &str) -> Result { + schema_section(schema, "-- RUSTBOX:GLOBAL-BEGIN", "-- RUSTBOX:GLOBAL-END") +} + +pub(crate) fn global_schema_for_backend(backend: DatabaseBackend) -> Result { + match backend { + DatabaseBackend::Sqlite => global_schema(SQLITE_SCHEMA), + DatabaseBackend::Mysql => global_schema(MYSQL_SCHEMA), + DatabaseBackend::Pgsql => global_schema(PGSQL_SCHEMA), + } +} + +fn tenant_schema(schema: &str, prefix: &str) -> Result { + Ok( + schema_section(schema, "-- RUSTBOX:TENANT-BEGIN", "-- RUSTBOX:TENANT-END")? + .replace("{{p}}", prefix), + ) +} + +fn sqlite_database_path(config: &AppConfig) -> Result { + if let Some(raw) = config + .database_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let without_scheme = raw + .strip_prefix("sqlite://") + .or_else(|| raw.strip_prefix("sqlite:")) + .unwrap_or(raw); + let path = without_scheme.split('?').next().unwrap_or_default().trim(); + if path.is_empty() || path == ":memory:" { + return Err("SQLite DATABASE_URL must point to a persistent file".to_string()); + } + return Ok(PathBuf::from(path)); + } + Ok(PathBuf::from(&config.data_dir).join("rustbox.sqlite")) +} + +pub async fn configure(config: &AppConfig) -> Result<(), String> { + if DATABASE.get().is_some() { + return Ok(()); + } + + let backend = DatabaseBackend::parse(&config.database_backend)?; + let configured = match backend { + DatabaseBackend::Sqlite => { + let path = sqlite_database_path(config)?; + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent) + .map_err(|e| format!("failed to create SQLite database directory: {e}"))?; + } + let conn = rusqlite::Connection::open(&path) + .map_err(|e| format!("failed to open SQLite database: {e}"))?; + conn.busy_timeout(std::time::Duration::from_secs(5)) + .map_err(|e| format!("failed to configure SQLite busy timeout: {e}"))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .map_err(|e| format!("failed to configure SQLite database: {e}"))?; + conn.execute_batch(&global_schema(SQLITE_SCHEMA)?) + .map_err(|e| format!("SQLite global schema initialization failed: {e}"))?; + ConfiguredDatabase::Sqlite { path } + } + DatabaseBackend::Mysql => { + let url = config + .database_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or("DATABASE_URL required for mysql")? + .to_string(); + let runtime = database_runtime()?; + let pool = runtime_wait(&runtime, async move { + MySqlPool::connect(&url) + .await + .map_err(|e| format!("failed to connect MySQL database: {e}")) + })?; + let schema = global_schema(MYSQL_SCHEMA)?; + let init_pool = pool.clone(); + runtime_wait(&runtime, async move { + sqlx::raw_sql(&schema) + .execute(&init_pool) + .await + .map_err(|e| format!("MySQL global schema initialization failed: {e}"))?; + Ok(()) + })?; + ConfiguredDatabase::Mysql { pool, runtime } + } + DatabaseBackend::Pgsql => { + let url = config + .database_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or("DATABASE_URL required for pgsql")? + .to_string(); + let runtime = database_runtime()?; + let pool = runtime_wait(&runtime, async move { + PgPool::connect(&url) + .await + .map_err(|e| format!("failed to connect PostgreSQL database: {e}")) + })?; + let schema = global_schema(PGSQL_SCHEMA)?; + let init_pool = pool.clone(); + runtime_wait(&runtime, async move { + sqlx::raw_sql(&schema) + .execute(&init_pool) + .await + .map_err(|e| format!("PostgreSQL global schema initialization failed: {e}"))?; + Ok(()) + })?; + ConfiguredDatabase::Pgsql { pool, runtime } + } + }; + + DATABASE + .set(configured) + .map_err(|_| "database already configured".to_string()) +} + +pub fn configured_backend() -> DatabaseBackend { + match DATABASE.get() { + Some(ConfiguredDatabase::Sqlite { .. }) | None => DatabaseBackend::Sqlite, + Some(ConfiguredDatabase::Mysql { .. }) => DatabaseBackend::Mysql, + Some(ConfiguredDatabase::Pgsql { .. }) => DatabaseBackend::Pgsql, + } +} + +#[macro_export] +macro_rules! dbq { + ($module:ident::$name:ident) => {{ + match $crate::db::pool::configured_backend() { + $crate::db::pool::DatabaseBackend::Sqlite => { + $crate::queries::sqlite::queries::$module::$name + } + $crate::db::pool::DatabaseBackend::Mysql => { + $crate::queries::mysql::queries::$module::$name + } + $crate::db::pool::DatabaseBackend::Pgsql => { + $crate::queries::pgsql::queries::$module::$name + } + } + }}; +} + +#[derive(Debug, Clone)] +pub enum DbError { + QueryReturnedNoRows, + Other(String), +} + +impl fmt::Display for DbError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::QueryReturnedNoRows => f.write_str("query returned no rows"), + Self::Other(message) => f.write_str(message), + } + } +} + +impl std::error::Error for DbError {} + +impl From for DbError { + fn from(error: rusqlite::Error) -> Self { + match error { + rusqlite::Error::QueryReturnedNoRows => Self::QueryReturnedNoRows, + other => Self::Other(other.to_string()), + } + } +} + +pub trait OptionalExtension { + fn optional(self) -> Result, DbError>; +} + +impl OptionalExtension for Result { + fn optional(self) -> Result, DbError> { + match self { + Ok(value) => Ok(Some(value)), + Err(DbError::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(error), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DbValue { + Text(Option), + I64(Option), + F64(Option), + Bytes(Option>), +} + +impl ToSql for DbValue { + fn to_sql(&self) -> rusqlite::Result> { + let value = match self { + Self::Text(Some(value)) => Value::Text(value.clone()), + Self::Text(None) => Value::Null, + Self::I64(Some(value)) => Value::Integer(*value), + Self::I64(None) => Value::Null, + Self::F64(Some(value)) => Value::Real(*value), + Self::F64(None) => Value::Null, + Self::Bytes(Some(value)) => Value::Blob(value.clone()), + Self::Bytes(None) => Value::Null, + }; + Ok(ToSqlOutput::Owned(value)) + } +} + +pub trait ToDbValue { + fn to_db_value(&self) -> DbValue; + fn null_db_value() -> DbValue + where + Self: Sized, + { + DbValue::Text(None) + } +} + +// Kept explicit rather than clever: the macro above intentionally stays simple for rustfmt/parser compatibility. +impl ToDbValue for i64 { + fn to_db_value(&self) -> DbValue { + DbValue::I64(Some(*self)) + } + fn null_db_value() -> DbValue { + DbValue::I64(None) + } +} +impl ToDbValue for i32 { + fn to_db_value(&self) -> DbValue { + DbValue::I64(Some(i64::from(*self))) + } + fn null_db_value() -> DbValue { + DbValue::I64(None) + } +} +impl ToDbValue for i16 { + fn to_db_value(&self) -> DbValue { + DbValue::I64(Some(i64::from(*self))) + } + fn null_db_value() -> DbValue { + DbValue::I64(None) + } +} +impl ToDbValue for u64 { + fn to_db_value(&self) -> DbValue { + DbValue::I64(Some((*self).min(i64::MAX as u64) as i64)) + } + fn null_db_value() -> DbValue { + DbValue::I64(None) + } +} +impl ToDbValue for u32 { + fn to_db_value(&self) -> DbValue { + DbValue::I64(Some(i64::from(*self))) + } + fn null_db_value() -> DbValue { + DbValue::I64(None) + } +} +impl ToDbValue for usize { + fn to_db_value(&self) -> DbValue { + DbValue::I64(Some((*self).min(i64::MAX as usize) as i64)) + } + fn null_db_value() -> DbValue { + DbValue::I64(None) + } +} +impl ToDbValue for bool { + fn to_db_value(&self) -> DbValue { + DbValue::I64(Some(if *self { 1 } else { 0 })) + } + fn null_db_value() -> DbValue { + DbValue::I64(None) + } +} +impl ToDbValue for f64 { + fn to_db_value(&self) -> DbValue { + DbValue::F64(Some(*self)) + } + fn null_db_value() -> DbValue { + DbValue::F64(None) + } +} +impl ToDbValue for String { + fn to_db_value(&self) -> DbValue { + DbValue::Text(Some(self.clone())) + } + fn null_db_value() -> DbValue { + DbValue::Text(None) + } +} +impl ToDbValue for str { + fn to_db_value(&self) -> DbValue { + DbValue::Text(Some(self.to_string())) + } +} +impl ToDbValue for Vec { + fn to_db_value(&self) -> DbValue { + DbValue::Bytes(Some(self.clone())) + } + fn null_db_value() -> DbValue { + DbValue::Bytes(None) + } +} +impl ToDbValue for [u8] { + fn to_db_value(&self) -> DbValue { + DbValue::Bytes(Some(self.to_vec())) + } +} +impl ToDbValue for Option { + fn to_db_value(&self) -> DbValue { + self.as_ref() + .map(ToDbValue::to_db_value) + .unwrap_or_else(T::null_db_value) + } + fn null_db_value() -> DbValue { + T::null_db_value() + } +} +impl ToDbValue for &T { + fn to_db_value(&self) -> DbValue { + (*self).to_db_value() + } + fn null_db_value() -> DbValue + where + Self: Sized, + { + DbValue::Text(None) + } +} + +#[macro_export] +macro_rules! db_params { + () => { Vec::<$crate::db::pool::DbValue>::new() }; + ($($value:expr),+ $(,)?) => {{ + vec![$($crate::db::pool::ToDbValue::to_db_value(&$value)),+] + }}; +} +pub use crate::db_params as params; + +pub trait IntoDbParams { + fn into_db_params(self) -> Vec; +} +impl IntoDbParams for Vec { + fn into_db_params(self) -> Vec { + self + } +} +impl IntoDbParams for &[DbValue] { + fn into_db_params(self) -> Vec { + self.to_vec() + } +} +impl IntoDbParams for [(); 0] { + fn into_db_params(self) -> Vec { + Vec::new() + } +} + +// Do not use a const-generic array implementation here. For an empty array (`[]`) +// Rust cannot infer the element type and sees both `[(); 0]` and `[T; 0]` as valid +// `IntoDbParams` candidates. Implement only non-empty array lengths explicitly so +// legacy/rusqlite-style `[]` call sites remain unambiguous. +macro_rules! impl_into_db_params_array { + ($($n:expr),+ $(,)?) => { + $( + impl IntoDbParams for [T; $n] { + fn into_db_params(self) -> Vec { + self.iter().map(ToDbValue::to_db_value).collect() + } + } + )+ + }; +} +impl_into_db_params_array!( + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, 32, +); + +#[derive(Clone)] +enum ConnectionInner { + Sqlite(Arc>), + MysqlPool { + pool: MySqlPool, + runtime: Handle, + }, + PgsqlPool { + pool: PgPool, + runtime: Handle, + }, + MysqlTx { + conn: Arc>>, + runtime: Handle, + }, + PgsqlTx { + conn: Arc>>, + runtime: Handle, + }, +} + +#[derive(Clone)] +pub struct Connection { + inner: ConnectionInner, + prefix: String, +} + +impl Connection { + pub fn backend(&self) -> DatabaseBackend { + match &self.inner { + ConnectionInner::Sqlite(_) => DatabaseBackend::Sqlite, + ConnectionInner::MysqlPool { .. } | ConnectionInner::MysqlTx { .. } => { + DatabaseBackend::Mysql + } + ConnectionInner::PgsqlPool { .. } | ConnectionInner::PgsqlTx { .. } => { + DatabaseBackend::Pgsql + } + } + } + + fn scoped_sql(&self, sql: &str) -> String { + prefix_tenant_tables(sql, &self.prefix) + } + + fn adapted_sql_params(&self, sql: &str, params: P) -> (String, Vec) { + let sql = self.scoped_sql(sql); + adapt_sql_params(self.backend(), sql, params.into_db_params()) + } + + pub fn execute(&self, sql: &str, params: P) -> Result { + let (sql, params) = self.adapted_sql_params(sql, params); + match &self.inner { + ConnectionInner::Sqlite(conn) => { + let conn = conn + .lock() + .map_err(|_| DbError::Other("SQLite mutex poisoned".into()))?; + conn.execute(&sql, rusqlite::params_from_iter(params.iter())) + .map_err(Into::into) + } + ConnectionInner::MysqlPool { pool, runtime } => { + let pool = pool.clone(); + runtime_wait(runtime, async move { + mysql_execute_pool(pool, sql, params).await + }) + .map_err(DbError::Other) + } + ConnectionInner::PgsqlPool { pool, runtime } => { + let pool = pool.clone(); + runtime_wait(runtime, async move { + pgsql_execute_pool(pool, sql, params).await + }) + .map_err(DbError::Other) + } + ConnectionInner::MysqlTx { conn, runtime } => { + let conn = Arc::clone(conn); + runtime_wait( + runtime, + async move { mysql_execute_tx(conn, sql, params).await }, + ) + .map_err(DbError::Other) + } + ConnectionInner::PgsqlTx { conn, runtime } => { + let conn = Arc::clone(conn); + runtime_wait( + runtime, + async move { pgsql_execute_tx(conn, sql, params).await }, + ) + .map_err(DbError::Other) + } + } + } + + pub fn insert_id(&self, sql: &str, params: P) -> Result { + let (sql, params) = self.adapted_sql_params(sql, params); + match &self.inner { + ConnectionInner::Sqlite(conn) => { + let conn = conn + .lock() + .map_err(|_| DbError::Other("SQLite mutex poisoned".into()))?; + conn.execute(&sql, rusqlite::params_from_iter(params.iter())) + .map_err(DbError::from)?; + Ok(conn.last_insert_rowid()) + } + ConnectionInner::MysqlPool { pool, runtime } => { + let pool = pool.clone(); + runtime_wait(runtime, async move { + mysql_insert_id_pool(pool, sql, params).await + }) + .map_err(DbError::Other) + } + ConnectionInner::MysqlTx { conn, runtime } => { + let conn = Arc::clone(conn); + runtime_wait(runtime, async move { + mysql_insert_id_tx(conn, sql, params).await + }) + .map_err(DbError::Other) + } + ConnectionInner::PgsqlPool { pool, runtime } => { + let pool = pool.clone(); + runtime_wait(runtime, async move { + pgsql_insert_id_pool(pool, sql, params).await + }) + .map_err(DbError::Other) + } + ConnectionInner::PgsqlTx { conn, runtime } => { + let conn = Arc::clone(conn); + runtime_wait(runtime, async move { + pgsql_insert_id_tx(conn, sql, params).await + }) + .map_err(DbError::Other) + } + } + } + + pub fn execute_batch(&self, sql: &str) -> Result<(), DbError> { + let sql = self.scoped_sql(sql); + match &self.inner { + ConnectionInner::Sqlite(conn) => { + let conn = conn + .lock() + .map_err(|_| DbError::Other("SQLite mutex poisoned".into()))?; + conn.execute_batch(&sql).map_err(Into::into) + } + ConnectionInner::MysqlPool { pool, runtime } => { + let pool = pool.clone(); + runtime_wait(runtime, async move { + sqlx::raw_sql(&sql) + .execute(&pool) + .await + .map_err(|e| e.to_string())?; + Ok(()) + }) + .map_err(DbError::Other) + } + ConnectionInner::PgsqlPool { pool, runtime } => { + let pool = pool.clone(); + runtime_wait(runtime, async move { + sqlx::raw_sql(&sql) + .execute(&pool) + .await + .map_err(|e| e.to_string())?; + Ok(()) + }) + .map_err(DbError::Other) + } + ConnectionInner::MysqlTx { conn, runtime } => { + let conn = Arc::clone(conn); + runtime_wait(runtime, async move { + mysql_execute_tx(conn, sql, Vec::new()).await.map(|_| ()) + }) + .map_err(DbError::Other) + } + ConnectionInner::PgsqlTx { conn, runtime } => { + let conn = Arc::clone(conn); + runtime_wait(runtime, async move { + pgsql_execute_tx(conn, sql, Vec::new()).await.map(|_| ()) + }) + .map_err(DbError::Other) + } + } + } + + pub fn query_row(&self, sql: &str, params: P, map: F) -> Result + where + P: IntoDbParams, + F: FnOnce(&Row<'_>) -> Result, + { + let (sql, params) = self.adapted_sql_params(sql, params); + match &self.inner { + ConnectionInner::Sqlite(conn) => { + let conn = conn + .lock() + .map_err(|_| DbError::Other("SQLite mutex poisoned".into()))?; + let mut statement = conn.prepare(&sql).map_err(DbError::from)?; + let mut rows = statement + .query(rusqlite::params_from_iter(params.iter())) + .map_err(DbError::from)?; + let row = rows + .next() + .map_err(DbError::from)? + .ok_or(DbError::QueryReturnedNoRows)?; + map(&Row::Sqlite(row)) + } + _ => { + let mut rows = self.fetch_rows_owned(sql, params)?; + let row = rows.drain(..).next().ok_or(DbError::QueryReturnedNoRows)?; + map(&row.as_row()) + } + } + } + + pub fn prepare(&self, sql: &str) -> Result { + Ok(Statement { + connection: self.clone(), + sql: self.scoped_sql(sql), + }) + } + + pub fn transaction(&mut self) -> Result { + self.begin_transaction() + } + + pub fn unchecked_transaction(&self) -> Result { + self.begin_transaction() + } + + fn begin_transaction(&self) -> Result { + let tx_connection = match &self.inner { + ConnectionInner::Sqlite(conn) => { + { + let conn = conn + .lock() + .map_err(|_| DbError::Other("SQLite mutex poisoned".into()))?; + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(DbError::from)?; + } + Connection { + inner: ConnectionInner::Sqlite(Arc::clone(conn)), + prefix: self.prefix.clone(), + } + } + ConnectionInner::MysqlPool { pool, runtime } => { + let pool = pool.clone(); + let conn = runtime_wait(runtime, async move { + let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; + sqlx::query("START TRANSACTION") + .execute(&mut *conn) + .await + .map_err(|e| e.to_string())?; + Ok(conn) + }) + .map_err(DbError::Other)?; + Connection { + inner: ConnectionInner::MysqlTx { + conn: Arc::new(AsyncMutex::new(conn)), + runtime: runtime.clone(), + }, + prefix: self.prefix.clone(), + } + } + ConnectionInner::PgsqlPool { pool, runtime } => { + let pool = pool.clone(); + let conn = runtime_wait(runtime, async move { + let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .map_err(|e| e.to_string())?; + Ok(conn) + }) + .map_err(DbError::Other)?; + Connection { + inner: ConnectionInner::PgsqlTx { + conn: Arc::new(AsyncMutex::new(conn)), + runtime: runtime.clone(), + }, + prefix: self.prefix.clone(), + } + } + ConnectionInner::MysqlTx { .. } | ConnectionInner::PgsqlTx { .. } => { + return Err(DbError::Other( + "nested database transactions are not supported".into(), + )); + } + }; + Ok(Transaction { + connection: tx_connection, + finished: false, + }) + } + + fn fetch_rows_owned( + &self, + sql: String, + params: Vec, + ) -> Result, DbError> { + match &self.inner { + ConnectionInner::Sqlite(_) => { + Err(DbError::Other("internal SQLite fetch path mismatch".into())) + } + ConnectionInner::MysqlPool { pool, runtime } => { + let pool = pool.clone(); + runtime_wait( + runtime, + async move { mysql_fetch_pool(pool, sql, params).await }, + ) + .map(|rows| rows.into_iter().map(OwnedRow::Mysql).collect()) + .map_err(DbError::Other) + } + ConnectionInner::PgsqlPool { pool, runtime } => { + let pool = pool.clone(); + runtime_wait( + runtime, + async move { pgsql_fetch_pool(pool, sql, params).await }, + ) + .map(|rows| rows.into_iter().map(OwnedRow::Pgsql).collect()) + .map_err(DbError::Other) + } + ConnectionInner::MysqlTx { conn, runtime } => { + let conn = Arc::clone(conn); + runtime_wait( + runtime, + async move { mysql_fetch_tx(conn, sql, params).await }, + ) + .map(|rows| rows.into_iter().map(OwnedRow::Mysql).collect()) + .map_err(DbError::Other) + } + ConnectionInner::PgsqlTx { conn, runtime } => { + let conn = Arc::clone(conn); + runtime_wait( + runtime, + async move { pgsql_fetch_tx(conn, sql, params).await }, + ) + .map(|rows| rows.into_iter().map(OwnedRow::Pgsql).collect()) + .map_err(DbError::Other) + } + } + } +} + +pub struct Transaction { + connection: Connection, + finished: bool, +} + +impl Transaction { + pub fn commit(mut self) -> Result<(), DbError> { + self.connection.execute_batch("COMMIT")?; + self.finished = true; + Ok(()) + } +} + +impl Deref for Transaction { + type Target = Connection; + fn deref(&self) -> &Self::Target { + &self.connection + } +} + +impl Drop for Transaction { + fn drop(&mut self) { + if !self.finished { + let _ = self.connection.execute_batch("ROLLBACK"); + } + } +} + +pub struct Statement { + connection: Connection, + sql: String, +} + +impl Statement { + pub fn query_map(&mut self, params: P, mut map: F) -> Result, DbError> + where + P: IntoDbParams, + F: FnMut(&Row<'_>) -> Result, + { + let (sql, params) = adapt_sql_params( + self.connection.backend(), + self.sql.clone(), + params.into_db_params(), + ); + let mut output = Vec::new(); + match &self.connection.inner { + ConnectionInner::Sqlite(conn) => { + let conn = conn + .lock() + .map_err(|_| DbError::Other("SQLite mutex poisoned".into()))?; + let mut statement = conn.prepare(&sql).map_err(DbError::from)?; + let mut rows = statement + .query(rusqlite::params_from_iter(params.iter())) + .map_err(DbError::from)?; + while let Some(row) = rows.next().map_err(DbError::from)? { + output.push(map(&Row::Sqlite(row))); + } + } + _ => { + for row in self.connection.fetch_rows_owned(sql.clone(), params)? { + output.push(map(&row.as_row())); + } + } + } + Ok(MappedRows { + inner: output.into_iter(), + }) + } +} + +pub struct MappedRows { + inner: std::vec::IntoIter>, +} +impl Iterator for MappedRows { + type Item = Result; + fn next(&mut self) -> Option { + self.inner.next() + } +} + +enum OwnedRow { + Mysql(sqlx::mysql::MySqlRow), + Pgsql(sqlx::postgres::PgRow), +} +impl OwnedRow { + fn as_row(&self) -> Row<'_> { + match self { + Self::Mysql(row) => Row::Mysql(row), + Self::Pgsql(row) => Row::Pgsql(row), + } + } +} + +pub enum Row<'a> { + Sqlite(&'a rusqlite::Row<'a>), + Mysql(&'a sqlx::mysql::MySqlRow), + Pgsql(&'a sqlx::postgres::PgRow), +} + +pub enum DbColumn<'a> { + Index(usize), + Name(&'a str), +} +pub trait IntoDbColumn<'a> { + fn into_db_column(self) -> DbColumn<'a>; +} +impl<'a> IntoDbColumn<'a> for usize { + fn into_db_column(self) -> DbColumn<'a> { + DbColumn::Index(self) + } +} +impl<'a> IntoDbColumn<'a> for &'a str { + fn into_db_column(self) -> DbColumn<'a> { + DbColumn::Name(self) + } +} + +pub trait DbDecode: Sized { + fn decode(row: &Row<'_>, column: DbColumn<'_>) -> Result; +} + +impl<'a> Row<'a> { + pub fn get(&self, index: I) -> Result + where + I: IntoDbColumn<'a>, + T: DbDecode, + { + T::decode(self, index.into_db_column()) + } +} + +macro_rules! decode_direct { + ($ty:ty) => { + impl DbDecode for $ty { + fn decode(row: &Row<'_>, column: DbColumn<'_>) -> Result { + match row { + Row::Sqlite(row) => match column { + DbColumn::Index(i) => row.get::<_, $ty>(i).map_err(DbError::from), + DbColumn::Name(n) => row.get::<_, $ty>(n).map_err(DbError::from), + }, + Row::Mysql(row) => match column { + DbColumn::Index(i) => row + .try_get::<$ty, _>(i) + .map_err(|e| DbError::Other(e.to_string())), + DbColumn::Name(n) => row + .try_get::<$ty, _>(n) + .map_err(|e| DbError::Other(e.to_string())), + }, + Row::Pgsql(row) => match column { + DbColumn::Index(i) => row + .try_get::<$ty, _>(i) + .map_err(|e| DbError::Other(e.to_string())), + DbColumn::Name(n) => row + .try_get::<$ty, _>(n) + .map_err(|e| DbError::Other(e.to_string())), + }, + } + } + } + }; +} +decode_direct!(String); +decode_direct!(Option); +decode_direct!(Vec); +decode_direct!(Option>); +decode_direct!(i64); +decode_direct!(Option); +decode_direct!(f64); +decode_direct!(Option); +fn mysql_bool_by_index(row: &sqlx::mysql::MySqlRow, index: usize) -> Result { + row.try_get::(index) + .map(|value| value != 0) + .or_else(|_| row.try_get::(index).map(|value| value != 0)) + .or_else(|_| row.try_get::(index).map(|value| value != 0)) + .or_else(|_| row.try_get::(index).map(|value| value != 0)) + .map_err(|e| DbError::Other(e.to_string())) +} +fn mysql_bool_by_name(row: &sqlx::mysql::MySqlRow, name: &str) -> Result { + row.try_get::(name) + .map(|value| value != 0) + .or_else(|_| row.try_get::(name).map(|value| value != 0)) + .or_else(|_| row.try_get::(name).map(|value| value != 0)) + .or_else(|_| row.try_get::(name).map(|value| value != 0)) + .map_err(|e| DbError::Other(e.to_string())) +} +fn mysql_optional_bool_by_index( + row: &sqlx::mysql::MySqlRow, + index: usize, +) -> Result, DbError> { + row.try_get::, _>(index) + .map(|value| value.map(|n| n != 0)) + .or_else(|_| { + row.try_get::, _>(index) + .map(|value| value.map(|n| n != 0)) + }) + .or_else(|_| { + row.try_get::, _>(index) + .map(|value| value.map(|n| n != 0)) + }) + .or_else(|_| { + row.try_get::, _>(index) + .map(|value| value.map(|n| n != 0)) + }) + .map_err(|e| DbError::Other(e.to_string())) +} +fn mysql_optional_bool_by_name( + row: &sqlx::mysql::MySqlRow, + name: &str, +) -> Result, DbError> { + row.try_get::, _>(name) + .map(|value| value.map(|n| n != 0)) + .or_else(|_| { + row.try_get::, _>(name) + .map(|value| value.map(|n| n != 0)) + }) + .or_else(|_| { + row.try_get::, _>(name) + .map(|value| value.map(|n| n != 0)) + }) + .or_else(|_| { + row.try_get::, _>(name) + .map(|value| value.map(|n| n != 0)) + }) + .map_err(|e| DbError::Other(e.to_string())) +} + +impl DbDecode for bool { + fn decode(row: &Row<'_>, column: DbColumn<'_>) -> Result { + match row { + Row::Sqlite(row) => match column { + DbColumn::Index(i) => row.get::<_, bool>(i).map_err(DbError::from), + DbColumn::Name(n) => row.get::<_, bool>(n).map_err(DbError::from), + }, + Row::Mysql(row) => match column { + DbColumn::Index(i) => mysql_bool_by_index(row, i), + DbColumn::Name(n) => mysql_bool_by_name(row, n), + }, + Row::Pgsql(row) => match column { + DbColumn::Index(i) => row + .try_get::(i) + .or_else(|_| row.try_get::(i).map(|value| value != 0)) + .map_err(|e| DbError::Other(e.to_string())), + DbColumn::Name(n) => row + .try_get::(n) + .or_else(|_| row.try_get::(n).map(|value| value != 0)) + .map_err(|e| DbError::Other(e.to_string())), + }, + } + } +} +impl DbDecode for Option { + fn decode(row: &Row<'_>, column: DbColumn<'_>) -> Result { + match row { + Row::Sqlite(row) => match column { + DbColumn::Index(i) => row.get::<_, Option>(i).map_err(DbError::from), + DbColumn::Name(n) => row.get::<_, Option>(n).map_err(DbError::from), + }, + Row::Mysql(row) => match column { + DbColumn::Index(i) => mysql_optional_bool_by_index(row, i), + DbColumn::Name(n) => mysql_optional_bool_by_name(row, n), + }, + Row::Pgsql(row) => match column { + DbColumn::Index(i) => row + .try_get::, _>(i) + .or_else(|_| { + row.try_get::, _>(i) + .map(|value| value.map(|n| n != 0)) + }) + .map_err(|e| DbError::Other(e.to_string())), + DbColumn::Name(n) => row + .try_get::, _>(n) + .or_else(|_| { + row.try_get::, _>(n) + .map(|value| value.map(|n| n != 0)) + }) + .map_err(|e| DbError::Other(e.to_string())), + }, + } + } +} + +macro_rules! decode_via_i64 { + ($ty:ty) => { + impl DbDecode for $ty { + fn decode(row: &Row<'_>, column: DbColumn<'_>) -> Result { + let value = i64::decode(row, column)?; + <$ty>::try_from(value).map_err(|_| { + DbError::Other(format!( + "database integer {value} does not fit {}", + stringify!($ty) + )) + }) + } + } + impl DbDecode for Option<$ty> { + fn decode(row: &Row<'_>, column: DbColumn<'_>) -> Result { + let value = Option::::decode(row, column)?; + value + .map(|v| { + <$ty>::try_from(v).map_err(|_| { + DbError::Other(format!( + "database integer {v} does not fit {}", + stringify!($ty) + )) + }) + }) + .transpose() + } + } + }; +} +decode_via_i64!(i32); +decode_via_i64!(u32); +decode_via_i64!(u64); +decode_via_i64!(usize); + +fn bind_mysql<'q>( + mut query: sqlx::query::Query<'q, MySql, sqlx::mysql::MySqlArguments>, + params: Vec, +) -> sqlx::query::Query<'q, MySql, sqlx::mysql::MySqlArguments> { + for value in params { + query = match value { + DbValue::Text(value) => query.bind(value), + DbValue::I64(value) => query.bind(value), + DbValue::F64(value) => query.bind(value), + DbValue::Bytes(value) => query.bind(value), + }; + } + query +} +fn bind_pgsql<'q>( + mut query: sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments>, + params: Vec, +) -> sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments> { + for value in params { + query = match value { + DbValue::Text(value) => query.bind(value), + DbValue::I64(value) => query.bind(value), + DbValue::F64(value) => query.bind(value), + DbValue::Bytes(value) => query.bind(value), + }; + } + query +} + +async fn mysql_insert_id_pool( + pool: MySqlPool, + sql: String, + params: Vec, +) -> Result { + let result = bind_mysql(sqlx::query(&sql), params) + .execute(&pool) + .await + .map_err(|e| e.to_string())?; + i64::try_from(result.last_insert_id()).map_err(|_| "MySQL insert id exceeds i64".to_string()) +} +async fn mysql_insert_id_tx( + conn: Arc>>, + sql: String, + params: Vec, +) -> Result { + let mut conn = conn.lock().await; + let result = bind_mysql(sqlx::query(&sql), params) + .execute(&mut **conn) + .await + .map_err(|e| e.to_string())?; + i64::try_from(result.last_insert_id()).map_err(|_| "MySQL insert id exceeds i64".to_string()) +} +fn with_returning_id(sql: String) -> String { + let trimmed = sql.trim_end().trim_end_matches(';').trim_end(); + format!("{trimmed} RETURNING id") +} +async fn pgsql_insert_id_pool( + pool: PgPool, + sql: String, + params: Vec, +) -> Result { + let sql = with_returning_id(sql); + let row = bind_pgsql(sqlx::query(&sql), params) + .fetch_one(&pool) + .await + .map_err(|e| e.to_string())?; + row.try_get::(0).map_err(|e| e.to_string()) +} +async fn pgsql_insert_id_tx( + conn: Arc>>, + sql: String, + params: Vec, +) -> Result { + let sql = with_returning_id(sql); + let mut conn = conn.lock().await; + let row = bind_pgsql(sqlx::query(&sql), params) + .fetch_one(&mut **conn) + .await + .map_err(|e| e.to_string())?; + row.try_get::(0).map_err(|e| e.to_string()) +} + +async fn mysql_execute_pool( + pool: MySqlPool, + sql: String, + params: Vec, +) -> Result { + let result = bind_mysql(sqlx::query(&sql), params) + .execute(&pool) + .await + .map_err(|e| e.to_string())?; + Ok(result.rows_affected() as usize) +} +async fn pgsql_execute_pool( + pool: PgPool, + sql: String, + params: Vec, +) -> Result { + let result = bind_pgsql(sqlx::query(&sql), params) + .execute(&pool) + .await + .map_err(|e| e.to_string())?; + Ok(result.rows_affected() as usize) +} +async fn mysql_execute_tx( + conn: Arc>>, + sql: String, + params: Vec, +) -> Result { + let mut conn = conn.lock().await; + let result = bind_mysql(sqlx::query(&sql), params) + .execute(&mut **conn) + .await + .map_err(|e| e.to_string())?; + Ok(result.rows_affected() as usize) +} +async fn pgsql_execute_tx( + conn: Arc>>, + sql: String, + params: Vec, +) -> Result { + let mut conn = conn.lock().await; + let result = bind_pgsql(sqlx::query(&sql), params) + .execute(&mut **conn) + .await + .map_err(|e| e.to_string())?; + Ok(result.rows_affected() as usize) +} +async fn mysql_fetch_pool( + pool: MySqlPool, + sql: String, + params: Vec, +) -> Result, String> { + bind_mysql(sqlx::query(&sql), params) + .fetch_all(&pool) + .await + .map_err(|e| e.to_string()) +} +async fn pgsql_fetch_pool( + pool: PgPool, + sql: String, + params: Vec, +) -> Result, String> { + bind_pgsql(sqlx::query(&sql), params) + .fetch_all(&pool) + .await + .map_err(|e| e.to_string()) +} +async fn mysql_fetch_tx( + conn: Arc>>, + sql: String, + params: Vec, +) -> Result, String> { + let mut conn = conn.lock().await; + bind_mysql(sqlx::query(&sql), params) + .fetch_all(&mut **conn) + .await + .map_err(|e| e.to_string()) +} +async fn pgsql_fetch_tx( + conn: Arc>>, + sql: String, + params: Vec, +) -> Result, String> { + let mut conn = conn.lock().await; + bind_pgsql(sqlx::query(&sql), params) + .fetch_all(&mut **conn) + .await + .map_err(|e| e.to_string()) +} + +fn adapt_sql_params( + backend: DatabaseBackend, + mut sql: String, + params: Vec, +) -> (String, Vec) { + if backend == DatabaseBackend::Sqlite { + return (sql, params); + } + + // Dynamic query builders in the application use SQLite-style ?N placeholders. + // Normalize them here so the same higher-level logic can target all engines. + let chars: Vec = sql.chars().collect(); + let mut output = String::with_capacity(sql.len() + 16); + let mut reordered = Vec::new(); + let mut sequential = 0usize; + let mut i = 0usize; + let mut quote: Option = None; + while i < chars.len() { + let ch = chars[i]; + if let Some(q) = quote { + output.push(ch); + if ch == q { + if i + 1 < chars.len() && chars[i + 1] == q { + output.push(chars[i + 1]); + i += 2; + continue; + } + quote = None; + } + i += 1; + continue; + } + if matches!(ch, '\'' | '"' | '`') { + quote = Some(ch); + output.push(ch); + i += 1; + continue; + } + if ch == '?' { + let mut j = i + 1; + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + let index = if j > i + 1 { + chars[i + 1..j] + .iter() + .collect::() + .parse::() + .ok() + .and_then(|value| value.checked_sub(1)) + } else { + let value = sequential; + sequential += 1; + Some(value) + }; + if backend == DatabaseBackend::Mysql { + output.push('?'); + if let Some(index) = index.and_then(|index| params.get(index).map(|_| index)) { + reordered.push(params[index].clone()); + } + } else { + let number = index.map(|index| index + 1).unwrap_or(1); + output.push('$'); + output.push_str(&number.to_string()); + } + i = j; + continue; + } + output.push(ch); + i += 1; + } + + sql = output; + if backend == DatabaseBackend::Mysql && !reordered.is_empty() { + // If numbered placeholders were expanded, bind in occurrence order. + // Plain MySQL catalog queries already use '?' and are handled the same way. + return (adapt_dialect_sql(backend, sql), reordered); + } + (adapt_dialect_sql(backend, sql), params) +} + +fn adapt_dialect_sql(backend: DatabaseBackend, sql: String) -> String { + match backend { + DatabaseBackend::Sqlite => sql, + DatabaseBackend::Mysql => sql + .replace("datetime('now')", "CURRENT_TIMESTAMP") + .replace("datetime(\"now\")", "CURRENT_TIMESTAMP"), + DatabaseBackend::Pgsql => sql + .replace("datetime('now')", "CURRENT_TIMESTAMP::text") + .replace("datetime(\"now\")", "CURRENT_TIMESTAMP::text") + .replace("instr(", "strpos("), + } +} + +fn prefix_tenant_tables(sql: &str, prefix: &str) -> String { + let mut output = String::with_capacity(sql.len() + 32); + let chars: Vec = sql.chars().collect(); + let mut i = 0usize; + let mut quote: Option = None; + let mut expect_table = false; + + while i < chars.len() { + let ch = chars[i]; + if let Some(q) = quote { + output.push(ch); + if ch == q { + if i + 1 < chars.len() && chars[i + 1] == q { + output.push(chars[i + 1]); + i += 2; + continue; + } + quote = None; + } + i += 1; + continue; + } + if matches!(ch, '\'' | '"' | '`') { + quote = Some(ch); + output.push(ch); + i += 1; + continue; + } + if ch.is_ascii_alphabetic() || ch == '_' { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + let token: String = chars[start..i].iter().collect(); + let upper = token.to_ascii_uppercase(); + let mut next = i; + while next < chars.len() && chars[next].is_ascii_whitespace() { + next += 1; + } + let is_qualifier = next < chars.len() && chars[next] == '.'; + let is_tenant_table = TENANT_TABLES.contains(&token.as_str()); + if is_tenant_table && (expect_table || is_qualifier) { + output.push_str(prefix); + } + output.push_str(&token); + + if expect_table { + expect_table = false; + } + if matches!( + upper.as_str(), + "FROM" | "JOIN" | "UPDATE" | "INTO" | "TABLE" | "REFERENCES" + ) { + expect_table = true; + } + continue; + } + output.push(ch); + i += 1; + } + output +} + +#[derive(Clone)] +struct LegacyTableData { + name: String, + columns: Vec, + rows: Vec>, +} + +fn null_value_for_declared_type(declared: &str) -> DbValue { + let upper = declared.to_ascii_uppercase(); + if upper.contains("INT") { + DbValue::I64(None) + } else if upper.contains("REAL") || upper.contains("FLOA") || upper.contains("DOUB") { + DbValue::F64(None) + } else if upper.contains("BLOB") || upper.contains("BINARY") { + DbValue::Bytes(None) + } else { + DbValue::Text(None) + } +} + +fn read_legacy_sqlite(path: &Path) -> Result, String> { + use rusqlite::types::ValueRef; + + let conn = rusqlite::Connection::open(path).map_err(|e| { + format!( + "failed to open legacy SQLite database {}: {e}", + path.display() + ) + })?; + let version: u32 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(|e| format!("failed to read legacy SQLite schema version: {e}"))?; + if version < BASE_SCHEMA_VERSION { + return Err(format!( + "legacy SQLite schema version {version} is older than the RustBox 0.1.1 baseline {BASE_SCHEMA_VERSION}; upgrade with RustBox 0.1.0 first" + )); + } + if version > BASE_SCHEMA_VERSION { + return Err(format!( + "legacy SQLite schema version {version} is newer than the RustBox 0.1.1 baseline {BASE_SCHEMA_VERSION}" + )); + } + let mut output = Vec::new(); + for &table in IMPORT_TABLES { + let exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + [table], + |row| row.get(0), + ) + .map_err(|e| format!("failed to inspect legacy table {table}: {e}"))?; + if exists == 0 { + continue; + } + + let escaped = table.replace('"', "\"\""); + let mut info = conn + .prepare(&format!("PRAGMA table_info(\"{escaped}\")")) + .map_err(|e| format!("failed to inspect legacy columns for {table}: {e}"))?; + let columns_with_types: Vec<(String, String)> = info + .query_map([], |row| Ok((row.get(1)?, row.get(2)?))) + .map_err(|e| format!("failed to query legacy columns for {table}: {e}"))? + .collect::>>() + .map_err(|e| format!("failed to read legacy columns for {table}: {e}"))?; + drop(info); + + let columns: Vec = columns_with_types + .iter() + .map(|(name, _)| name.clone()) + .collect(); + let quoted_columns = columns + .iter() + .map(|name| format!("\"{}\"", name.replace('"', "\"\""))) + .collect::>() + .join(", "); + let mut statement = conn + .prepare(&format!("SELECT {quoted_columns} FROM \"{escaped}\"")) + .map_err(|e| format!("failed to prepare legacy read for {table}: {e}"))?; + let mut rows = statement + .query([]) + .map_err(|e| format!("failed to query legacy table {table}: {e}"))?; + let mut table_rows = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|e| format!("failed to read legacy row from {table}: {e}"))? + { + let mut values = Vec::with_capacity(columns.len()); + for (index, (_, declared_type)) in columns_with_types.iter().enumerate() { + let value = match row + .get_ref(index) + .map_err(|e| format!("failed to read legacy value {table}[{index}]: {e}"))? + { + ValueRef::Null => null_value_for_declared_type(declared_type), + ValueRef::Integer(value) => DbValue::I64(Some(value)), + ValueRef::Real(value) => DbValue::F64(Some(value)), + ValueRef::Text(value) => { + DbValue::Text(Some(String::from_utf8_lossy(value).into_owned())) + } + ValueRef::Blob(value) => DbValue::Bytes(Some(value.to_vec())), + }; + values.push(value); + } + table_rows.push(values); + } + output.push(LegacyTableData { + name: table.to_string(), + columns, + rows: table_rows, + }); + } + Ok(output) +} + +fn tenant_seed_sql(backend: DatabaseBackend, prefix: &str) -> String { + match backend { + DatabaseBackend::Sqlite => format!( + "INSERT OR IGNORE INTO {prefix}user_meta (key, value) VALUES ('schema_version', '1');\n\ + INSERT OR IGNORE INTO {prefix}notification_preferences (id) VALUES (1);\n\ + INSERT OR IGNORE INTO {prefix}mail_preferences (id) VALUES (1);\n\ + INSERT OR IGNORE INTO {prefix}pgp_preferences (id) VALUES (1);" + ), + DatabaseBackend::Mysql => format!( + "INSERT IGNORE INTO {prefix}user_meta (`key`, value) VALUES ('schema_version', '1');\n\ + INSERT IGNORE INTO {prefix}notification_preferences (id) VALUES (1);\n\ + INSERT IGNORE INTO {prefix}mail_preferences (id) VALUES (1);\n\ + INSERT IGNORE INTO {prefix}pgp_preferences (id) VALUES (1);" + ), + DatabaseBackend::Pgsql => format!( + "INSERT INTO {prefix}user_meta (key, value) VALUES ('schema_version', '1') ON CONFLICT (key) DO NOTHING;\n\ + INSERT INTO {prefix}notification_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n\ + INSERT INTO {prefix}mail_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n\ + INSERT INTO {prefix}pgp_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;" + ), + } +} + +fn legacy_backup_path(path: &Path) -> PathBuf { + path.with_extension("sqlite.pre-0.1.1.bak") +} + +fn backup_legacy_sqlite(path: &Path) { + if path.is_file() { + let backup = legacy_backup_path(path); + if !backup.exists() { + let _ = fs::rename(path, backup); + } + } +} + +fn mysql_legacy_insert_sql(prefix: &str, table: &LegacyTableData) -> String { + let columns = table + .columns + .iter() + .map(|name| format!("`{}`", name.replace('`', "``"))) + .collect::>() + .join(", "); + let placeholders = (0..table.columns.len()) + .map(|_| "?") + .collect::>() + .join(", "); + format!( + "INSERT INTO {prefix}{} ({columns}) VALUES ({placeholders})", + table.name + ) +} + +fn pgsql_legacy_insert_sql(prefix: &str, table: &LegacyTableData) -> String { + let columns = table + .columns + .iter() + .map(|name| format!("\"{}\"", name.replace('"', "\"\""))) + .collect::>() + .join(", "); + let placeholders = (1..=table.columns.len()) + .map(|index| format!("${index}")) + .collect::>() + .join(", "); + format!( + "INSERT INTO {prefix}{} ({columns}) VALUES ({placeholders})", + table.name + ) +} + +async fn import_legacy_mysql( + pool: &MySqlPool, + prefix: &str, + tables: Vec, +) -> Result<(), String> { + for table in tables { + let sql = mysql_legacy_insert_sql(prefix, &table); + for row in table.rows { + mysql_execute_pool(pool.clone(), sql.clone(), row) + .await + .map_err(|e| { + format!( + "failed to import legacy table {} into MySQL: {e}", + table.name + ) + })?; + } + } + Ok(()) +} + +async fn import_legacy_pgsql( + pool: &PgPool, + prefix: &str, + tables: Vec, +) -> Result<(), String> { + for table in tables { + let sql = pgsql_legacy_insert_sql(prefix, &table); + for row in table.rows { + pgsql_execute_pool(pool.clone(), sql.clone(), row) + .await + .map_err(|e| { + format!( + "failed to import legacy table {} into PostgreSQL: {e}", + table.name + ) + })?; + } + } + for table in [ + "identities", + "meeting_templates", + "response_templates", + "saved_searches", + ] { + let qualified = format!("{prefix}{table}"); + let sql = format!( + "SELECT setval(pg_get_serial_sequence('{qualified}', 'id'), COALESCE(MAX(id), 1), MAX(id) IS NOT NULL) FROM {qualified}" + ); + sqlx::query(&sql) + .execute(pool) + .await + .map_err(|e| format!("failed to advance PostgreSQL sequence for {qualified}: {e}"))?; + } + Ok(()) +} + +fn tenant_prefix(user_hash: &str) -> Result { + if user_hash.is_empty() { + return Err("empty user hash for database isolation".to_string()); + } + + // Production user hashes are SHA-256 hex strings. Tests and legacy callers may + // use symbolic identifiers; hash those first so table prefixes remain safe, + // deterministic and restricted to ASCII hex without weakening isolation. + let normalized = if user_hash.len() >= 24 && user_hash.chars().all(|c| c.is_ascii_hexdigit()) { + user_hash.to_ascii_lowercase() + } else { + format!("{:x}", Sha256::digest(user_hash.as_bytes())) + }; + + Ok(format!("u_{}__", &normalized[..24])) +} + +fn latest_schema_version() -> u32 { + let migrations = match configured_backend() { + DatabaseBackend::Sqlite => SQLITE_MIGRATIONS, + DatabaseBackend::Mysql => MYSQL_MIGRATIONS, + DatabaseBackend::Pgsql => PGSQL_MIGRATIONS, + }; + migrations + .last() + .map(|(version, _)| *version) + .unwrap_or(BASE_SCHEMA_VERSION) +} + +fn configured_or_default_sqlite(data_dir: &str) -> Result { + if let Some(configured) = DATABASE.get() { + return Ok(configured.clone()); + } + let path = Path::new(data_dir).join("rustbox.sqlite"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("failed to create database directory: {e}"))?; + } + let conn = rusqlite::Connection::open(&path) + .map_err(|e| format!("failed to open SQLite database: {e}"))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .map_err(|e| format!("failed to configure SQLite database: {e}"))?; + conn.execute_batch(&global_schema(SQLITE_SCHEMA)?) + .map_err(|e| format!("SQLite global schema initialization failed: {e}"))?; + Ok(ConfiguredDatabase::Sqlite { path }) +} + +fn sqlite_legacy_insert_sql(prefix: &str, table: &LegacyTableData) -> String { + let columns = table + .columns + .iter() + .map(|name| format!("\"{}\"", name.replace('"', "\"\""))) + .collect::>() + .join(", "); + let placeholders = (1..=table.columns.len()) + .map(|index| format!("?{index}")) + .collect::>() + .join(", "); + format!( + "INSERT INTO {prefix}{} ({columns}) VALUES ({placeholders})", + table.name + ) +} + +fn import_legacy_sqlite_data( + tx: &rusqlite::Transaction<'_>, + prefix: &str, + tables: &[LegacyTableData], +) -> Result<(), String> { + for table in tables { + let sql = sqlite_legacy_insert_sql(prefix, table); + for row in &table.rows { + tx.execute(&sql, rusqlite::params_from_iter(row.iter())) + .map_err(|e| format!("failed to import legacy table {}: {e}", table.name))?; + } + } + Ok(()) +} + +fn ensure_sqlite_tenant( + path: &Path, + data_dir: &str, + user_hash: &str, + prefix: &str, +) -> Result<(), String> { + let mut conn = rusqlite::Connection::open(path) + .map_err(|e| format!("failed to open SQLite database: {e}"))?; + conn.busy_timeout(std::time::Duration::from_secs(5)) + .map_err(|e| e.to_string())?; + conn.execute_batch("PRAGMA foreign_keys=ON;") + .map_err(|e| e.to_string())?; + + let existing: Option<(String, u32)> = conn + .query_row( + "SELECT table_prefix, schema_version FROM rustbox_accounts WHERE user_hash=?1", + [user_hash], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + if let Some((stored_prefix, version)) = existing { + if stored_prefix != prefix { + return Err("database account-prefix collision detected".to_string()); + } + return apply_sqlite_migrations(&conn, prefix, version); + } + + let collision: Option = conn + .query_row( + "SELECT user_hash FROM rustbox_accounts WHERE table_prefix=?1", + [prefix], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + if collision.is_some() { + return Err("database account-prefix collision detected".to_string()); + } + + let legacy_path = Path::new(data_dir).join(user_hash).join("db.sqlite"); + let legacy_data = if legacy_path.is_file() { + read_legacy_sqlite(&legacy_path)? + } else { + Vec::new() + }; + + let tx = conn.transaction().map_err(|e| e.to_string())?; + tx.execute_batch(&tenant_schema(SQLITE_SCHEMA, prefix)?) + .map_err(|e| format!("SQLite tenant schema initialization failed: {e}"))?; + if !legacy_data.is_empty() { + import_legacy_sqlite_data(&tx, prefix, &legacy_data)?; + } + tx.execute_batch(&tenant_seed_sql(DatabaseBackend::Sqlite, prefix)) + .map_err(|e| format!("failed to initialize SQLite tenant defaults: {e}"))?; + tx.execute( + "INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES(?1, ?2, ?3)", + rusqlite::params![user_hash, prefix, BASE_SCHEMA_VERSION], + ) + .map_err(|e| format!("failed to register SQLite account schema: {e}"))?; + tx.commit().map_err(|e| e.to_string())?; + + if !legacy_data.is_empty() { + backup_legacy_sqlite(&legacy_path); + } + Ok(()) +} + +fn apply_sqlite_migrations( + conn: &rusqlite::Connection, + prefix: &str, + current: u32, +) -> Result<(), String> { + if current < BASE_SCHEMA_VERSION { + return Err(format!( + "database schema version {current} is older than the RustBox 0.1.1 baseline {BASE_SCHEMA_VERSION}; upgrade with RustBox 0.1.0 first" + )); + } + let latest = latest_schema_version(); + if current > latest { + return Err(format!( + "database schema version {current} is newer than this RustBox build supports ({latest})" + )); + } + for &(version, sql) in SQLITE_MIGRATIONS { + if version <= current { + continue; + } + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + tx.execute_batch(&sql.replace("{{p}}", prefix)) + .map_err(|e| format!("SQLite migration V{version:03} failed: {e}"))?; + tx.execute("UPDATE rustbox_accounts SET schema_version=?1, updated_at=datetime('now') WHERE table_prefix=?2", rusqlite::params![version, prefix]) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + } + Ok(()) +} + +async fn drop_mysql_tenant(pool: &MySqlPool, prefix: &str) { + let _ = sqlx::query("SET FOREIGN_KEY_CHECKS=0").execute(pool).await; + for table in IMPORT_TABLES.iter().rev() { + let sql = format!("DROP TABLE IF EXISTS {prefix}{table}"); + let _ = sqlx::query(&sql).execute(pool).await; + } + let _ = sqlx::query("SET FOREIGN_KEY_CHECKS=1").execute(pool).await; +} + +async fn drop_pgsql_tenant(pool: &PgPool, prefix: &str) { + for table in IMPORT_TABLES.iter().rev() { + let sql = format!("DROP TABLE IF EXISTS {prefix}{table} CASCADE"); + let _ = sqlx::query(&sql).execute(pool).await; + } +} + +fn ensure_server_tenant( + configured: &ConfiguredDatabase, + data_dir: &str, + user_hash: &str, + prefix: &str, +) -> Result<(), String> { + let legacy_path = Path::new(data_dir).join(user_hash).join("db.sqlite"); + + let imported_legacy = match configured { + ConfiguredDatabase::Mysql { pool, runtime } => { + let pool = pool.clone(); + let user_hash = user_hash.to_string(); + let prefix = prefix.to_string(); + let tenant = tenant_schema(MYSQL_SCHEMA, &prefix)?; + let legacy_path = legacy_path.clone(); + runtime_wait(runtime, async move { + // Serialize first-time provisioning per account across cluster nodes. + // MySQL named locks are session-scoped and are released automatically if the connection dies. + let lock_name = format!("rustbox:{prefix}"); + let mut lock_conn = pool + .acquire() + .await + .map_err(|e| format!("failed to acquire MySQL provisioning connection: {e}"))?; + let acquired: Option = sqlx::query_scalar("SELECT GET_LOCK(?, 10)") + .bind(&lock_name) + .fetch_one(&mut *lock_conn) + .await + .map_err(|e| { + format!("failed to acquire MySQL account provisioning lock: {e}") + })?; + if acquired != Some(1) { + return Err("timed out waiting for MySQL account provisioning lock".to_string()); + } + + let result: Result = async { + let existing: Option<(String, i64)> = sqlx::query_as("SELECT table_prefix, schema_version FROM rustbox_accounts WHERE user_hash=?") + .bind(&user_hash).fetch_optional(&pool).await.map_err(|e| e.to_string())?; + if let Some((stored, version)) = existing { + if stored != prefix { return Err("database account-prefix collision detected".into()); } + if version < i64::from(BASE_SCHEMA_VERSION) { return Err(format!("database schema version {version} is older than baseline {BASE_SCHEMA_VERSION}")); } + if version > i64::from(latest_schema_version()) { return Err(format!("database schema version {version} is newer than this build")); } + for &(migration_version, migration) in MYSQL_MIGRATIONS { + if i64::from(migration_version) <= version { continue; } + let rendered = migration.replace("{{p}}", &prefix); + sqlx::raw_sql(&rendered).execute(&pool).await.map_err(|e| format!("MySQL migration V{migration_version:03} failed: {e}"))?; + sqlx::query("UPDATE rustbox_accounts SET schema_version=?, updated_at=CURRENT_TIMESTAMP WHERE user_hash=?") + .bind(i64::from(migration_version)).bind(&user_hash).execute(&pool).await.map_err(|e| e.to_string())?; + } + return Ok(false); + } + let collision: Option = sqlx::query_scalar("SELECT user_hash FROM rustbox_accounts WHERE table_prefix=?") + .bind(&prefix).fetch_optional(&pool).await.map_err(|e| e.to_string())?; + if collision.is_some() { return Err("database account-prefix collision detected".into()); } + + let legacy_data = if legacy_path.is_file() { + read_legacy_sqlite(&legacy_path)? + } else { + Vec::new() + }; + let imported_legacy = !legacy_data.is_empty(); + + if let Err(error) = sqlx::raw_sql(&tenant).execute(&pool).await { + drop_mysql_tenant(&pool, &prefix).await; + return Err(format!("MySQL tenant schema initialization failed: {error}")); + } + if let Err(error) = import_legacy_mysql(&pool, &prefix, legacy_data).await { + drop_mysql_tenant(&pool, &prefix).await; + return Err(error); + } + if let Err(error) = sqlx::raw_sql(&tenant_seed_sql(DatabaseBackend::Mysql, &prefix)).execute(&pool).await { + drop_mysql_tenant(&pool, &prefix).await; + return Err(format!("failed to initialize MySQL tenant defaults: {error}")); + } + if let Err(error) = sqlx::query("INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES(?, ?, ?)") + .bind(&user_hash).bind(&prefix).bind(i64::from(BASE_SCHEMA_VERSION)).execute(&pool).await + { + drop_mysql_tenant(&pool, &prefix).await; + return Err(format!("failed to register MySQL account schema: {error}")); + } + Ok(imported_legacy) + }.await; + let _ = sqlx::query("SELECT RELEASE_LOCK(?)") + .bind(&lock_name) + .execute(&mut *lock_conn) + .await; + result + })? + } + ConfiguredDatabase::Pgsql { pool, runtime } => { + let pool = pool.clone(); + let user_hash = user_hash.to_string(); + let prefix = prefix.to_string(); + let tenant = tenant_schema(PGSQL_SCHEMA, &prefix)?; + let legacy_path = legacy_path.clone(); + runtime_wait(runtime, async move { + // PostgreSQL advisory locks are session-scoped, so they work across all RustBox nodes + // that share this database and disappear automatically with the connection. + let lock_name = format!("rustbox:{prefix}"); + let mut lock_conn = pool.acquire().await.map_err(|e| { + format!("failed to acquire PostgreSQL provisioning connection: {e}") + })?; + sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + .bind(&lock_name) + .execute(&mut *lock_conn) + .await + .map_err(|e| { + format!("failed to acquire PostgreSQL account provisioning lock: {e}") + })?; + + let result: Result = async { + let existing: Option<(String, i64)> = sqlx::query_as("SELECT table_prefix, schema_version FROM rustbox_accounts WHERE user_hash=$1") + .bind(&user_hash).fetch_optional(&pool).await.map_err(|e| e.to_string())?; + if let Some((stored, version)) = existing { + if stored != prefix { return Err("database account-prefix collision detected".into()); } + if version < i64::from(BASE_SCHEMA_VERSION) { return Err(format!("database schema version {version} is older than baseline {BASE_SCHEMA_VERSION}")); } + if version > i64::from(latest_schema_version()) { return Err(format!("database schema version {version} is newer than this build")); } + for &(migration_version, migration) in PGSQL_MIGRATIONS { + if i64::from(migration_version) <= version { continue; } + let rendered = migration.replace("{{p}}", &prefix); + sqlx::raw_sql(&rendered).execute(&pool).await.map_err(|e| format!("PostgreSQL migration V{migration_version:03} failed: {e}"))?; + sqlx::query("UPDATE rustbox_accounts SET schema_version=$1, updated_at=CURRENT_TIMESTAMP::text WHERE user_hash=$2") + .bind(i64::from(migration_version)).bind(&user_hash).execute(&pool).await.map_err(|e| e.to_string())?; + } + return Ok(false); + } + let collision: Option = sqlx::query_scalar("SELECT user_hash FROM rustbox_accounts WHERE table_prefix=$1") + .bind(&prefix).fetch_optional(&pool).await.map_err(|e| e.to_string())?; + if collision.is_some() { return Err("database account-prefix collision detected".into()); } + + let legacy_data = if legacy_path.is_file() { + read_legacy_sqlite(&legacy_path)? + } else { + Vec::new() + }; + let imported_legacy = !legacy_data.is_empty(); + + if let Err(error) = sqlx::raw_sql(&tenant).execute(&pool).await { + drop_pgsql_tenant(&pool, &prefix).await; + return Err(format!("PostgreSQL tenant schema initialization failed: {error}")); + } + if let Err(error) = import_legacy_pgsql(&pool, &prefix, legacy_data).await { + drop_pgsql_tenant(&pool, &prefix).await; + return Err(error); + } + if let Err(error) = sqlx::raw_sql(&tenant_seed_sql(DatabaseBackend::Pgsql, &prefix)).execute(&pool).await { + drop_pgsql_tenant(&pool, &prefix).await; + return Err(format!("failed to initialize PostgreSQL tenant defaults: {error}")); + } + if let Err(error) = sqlx::query("INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES($1, $2, $3)") + .bind(&user_hash).bind(&prefix).bind(i64::from(BASE_SCHEMA_VERSION)).execute(&pool).await + { + drop_pgsql_tenant(&pool, &prefix).await; + return Err(format!("failed to register PostgreSQL account schema: {error}")); + } + Ok(imported_legacy) + }.await; + let _ = sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") + .bind(&lock_name) + .execute(&mut *lock_conn) + .await; + result + })? + } + ConfiguredDatabase::Sqlite { .. } => false, + }; + + if imported_legacy { + backup_legacy_sqlite(&legacy_path); + } + Ok(()) +} + +pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result { + let prefix = tenant_prefix(user_hash)?; + let configured = configured_or_default_sqlite(data_dir)?; + match configured { + ConfiguredDatabase::Sqlite { path } => { + ensure_sqlite_tenant(&path, data_dir, user_hash, &prefix)?; + let conn = rusqlite::Connection::open(path) + .map_err(|e| format!("failed to open SQLite database: {e}"))?; + conn.busy_timeout(std::time::Duration::from_secs(5)) + .map_err(|e| e.to_string())?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .map_err(|e| e.to_string())?; + Ok(Connection { + inner: ConnectionInner::Sqlite(Arc::new(Mutex::new(conn))), + prefix, + }) + } + ConfiguredDatabase::Mysql { pool, runtime } => { + let configured = ConfiguredDatabase::Mysql { + pool: pool.clone(), + runtime: runtime.clone(), + }; + ensure_server_tenant(&configured, data_dir, user_hash, &prefix)?; + Ok(Connection { + inner: ConnectionInner::MysqlPool { pool, runtime }, + prefix, + }) + } + ConfiguredDatabase::Pgsql { pool, runtime } => { + let configured = ConfiguredDatabase::Pgsql { + pool: pool.clone(), + runtime: runtime.clone(), + }; + ensure_server_tenant(&configured, data_dir, user_hash, &prefix)?; + Ok(Connection { + inner: ConnectionInner::PgsqlPool { pool, runtime }, + prefix, + }) + } + } +} #[derive(Clone)] pub(crate) struct UserDb { data_dir: String, user_hash: String, } - impl UserDb { pub(crate) fn new(data_dir: &str, user_hash: &str) -> Self { Self { @@ -16,7 +2187,6 @@ impl UserDb { user_hash: user_hash.into(), } } - pub(crate) async fn run( &self, work: impl FnOnce(&Connection) -> Result + Send + 'static, @@ -31,239 +2201,27 @@ impl UserDb { } } -const MIGRATIONS: &[(u32, &str)] = &[ - ( - 1, - include_str!("../../migrations/sqlite/V001__initial_schema.sql"), - ), - ( - 2, - include_str!("../../migrations/sqlite/V002__folders_and_messages.sql"), - ), - ( - 3, - include_str!("../../migrations/sqlite/V003__add_date_epoch.sql"), - ), - ( - 4, - include_str!("../../migrations/sqlite/V004__folder_messages_updated_at.sql"), - ), - ( - 5, - include_str!("../../migrations/sqlite/V005__drafts_and_attachments.sql"), - ), - ( - 6, - include_str!("../../migrations/sqlite/V006__cache_attachments_and_headers.sql"), - ), - ( - 7, - include_str!("../../migrations/sqlite/V007__contacts.sql"), - ), - ( - 8, - include_str!("../../migrations/sqlite/V008__identities.sql"), - ), - ( - 9, - include_str!("../../migrations/sqlite/V009__notification_preferences.sql"), - ), - ( - 10, - include_str!("../../migrations/sqlite/V010__contact_groups.sql"), - ), - ( - 11, - include_str!("../../migrations/sqlite/V011__message_reaction.sql"), - ), - (12, include_str!("../../migrations/sqlite/V012__tags.sql")), - ( - 13, - include_str!("../../migrations/sqlite/V013__display_preferences.sql"), - ), - ( - 14, - include_str!("../../migrations/sqlite/V014__compose_format.sql"), - ), - ( - 15, - include_str!("../../migrations/sqlite/V015__deep_index.sql"), - ), - ( - 16, - include_str!("../../migrations/sqlite/V016__calendar.sql"), - ), - ( - 17, - include_str!("../../migrations/sqlite/V017__add_email_theme.sql"), - ), - ( - 18, - include_str!("../../migrations/sqlite/V018__animation_mode.sql"), - ), - ( - 19, - include_str!("../../migrations/sqlite/V019__known_addresses.sql"), - ), - ( - 20, - include_str!("../../migrations/sqlite/V020__appearance_personalization.sql"), - ), - ( - 21, - include_str!("../../migrations/sqlite/V021__special_folders.sql"), - ), - ( - 22, - include_str!("../../migrations/sqlite/V022__identity_options.sql"), - ), - ( - 23, - include_str!("../../migrations/sqlite/V023__saved_searches.sql"), - ), - ( - 24, - include_str!("../../migrations/sqlite/V024__notification_dnd.sql"), - ), - ( - 25, - include_str!("../../migrations/sqlite/V025__custom_theme_colors.sql"), - ), - ( - 26, - include_str!("../../migrations/sqlite/V026__mail_preferences.sql"), - ), - ( - 27, - include_str!("../../migrations/sqlite/V027__contact_profile.sql"), - ), - ( - 28, - include_str!("../../migrations/sqlite/V028__contact_multi_values.sql"), - ), - ( - 29, - include_str!("../../migrations/sqlite/V029__draft_send_options.sql"), - ), - ( - 30, - include_str!("../../migrations/sqlite/V030__folder_list_preferences.sql"), - ), - ( - 31, - include_str!("../../migrations/sqlite/V031__compose_preferences.sql"), - ), - ( - 32, - include_str!("../../migrations/sqlite/V032__advanced_appearance.sql"), - ), - ( - 33, - include_str!("../../migrations/sqlite/V033__draft_compose_format.sql"), - ), - ( - 34, - include_str!("../../migrations/sqlite/V034__imap_keyword_sync.sql"), - ), - ( - 35, - include_str!("../../migrations/sqlite/V035__compose_behavior_preferences.sql"), - ), - ( - 36, - include_str!("../../migrations/sqlite/V036__remote_draft_tracking.sql"), - ), - ( - 37, - include_str!("../../migrations/sqlite/V037__openpgp_keyring.sql"), - ), - ( - 38, - include_str!("../../migrations/sqlite/V038__identity_openpgp.sql"), - ), - ( - 39, - include_str!("../../migrations/sqlite/V039__draft_openpgp_options.sql"), - ), - ( - 40, - include_str!("../../migrations/sqlite/V040__draft_openpgp_attach_key.sql"), - ), - ( - 41, - include_str!("../../migrations/sqlite/V041__draft_openpgp_recipient_keys.sql"), - ), - ( - 42, - include_str!("../../migrations/sqlite/V042__trusted_senders.sql"), - ), - ( - 43, - include_str!("../../migrations/sqlite/V043__response_templates.sql"), - ), - ( - 44, - include_str!("../../migrations/sqlite/V044__interface_skin.sql"), - ), - ( - 45, - include_str!("../../migrations/sqlite/V045__response_template_format.sql"), - ), - ( - 46, - include_str!("../../migrations/sqlite/V046__preset_scale_unlock.sql"), - ), -]; - -fn run_migrations(conn: &Connection) -> Result<(), String> { - let current: u32 = conn - .query_row(crate::queries::sqlite::queries::db_pool::Q_001, [], |row| { - row.get(0) - }) - .map_err(|e| format!("Failed to read user_version: {e}"))?; - - for &(version, sql) in MIGRATIONS { - if version > current { - conn.execute_batch(sql) - .map_err(|e| format!("Migration V{version:03} failed: {e}"))?; - conn.execute_batch(&crate::queries::sqlite::queries::db_pool::set_user_version( - version, - )) - .map_err(|e| format!("Failed to set user_version to {version}: {e}"))?; - } - } - - Ok(()) -} - -pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result { - let dir = Path::new(data_dir).join(user_hash); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?; - - let db_path = dir.join("db.sqlite"); - let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?; - - conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_002) - .map_err(|e| format!("Failed to set WAL mode: {e}"))?; - - conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_003) - .map_err(|e| format!("Failed to enable foreign keys: {e}"))?; - - run_migrations(&conn)?; - - Ok(conn) -} - #[cfg(test)] pub fn open_test_db() -> Connection { - let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite"); - - conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_004) - .expect("Failed to enable foreign keys"); - - run_migrations(&conn).expect("Test migrations failed"); - - conn + let user_hash = format!("{:x}", Sha256::digest(b"rustbox-test")); + let raw = rusqlite::Connection::open_in_memory().expect("Failed to open in-memory SQLite"); + raw.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + raw.execute_batch(&global_schema(SQLITE_SCHEMA).unwrap()) + .unwrap(); + let prefix = tenant_prefix(&user_hash).unwrap(); + raw.execute_batch(&tenant_schema(SQLITE_SCHEMA, &prefix).unwrap()) + .unwrap(); + raw.execute_batch(&tenant_seed_sql(DatabaseBackend::Sqlite, &prefix)) + .unwrap(); + raw.execute( + "INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES(?1, ?2, ?3)", + rusqlite::params![user_hash, prefix, BASE_SCHEMA_VERSION], + ) + .unwrap(); + Connection { + inner: ConnectionInner::Sqlite(Arc::new(Mutex::new(raw))), + prefix, + } } #[cfg(test)] @@ -271,68 +2229,181 @@ mod tests { use super::*; #[test] - fn test_open_test_db_has_tables() { - let conn = open_test_db(); - - let tables: Vec = conn - .prepare(crate::queries::sqlite::queries::db_pool::Q_005) - .unwrap() - .query_map([], |row| row.get(0)) - .unwrap() - .filter_map(|r| r.ok()) - .collect(); - - assert!(tables.contains(&"user_meta".to_string())); - assert!(tables.contains(&"folders".to_string())); - assert!(tables.contains(&"messages".to_string())); - assert!(tables.contains(&"drafts".to_string())); - assert!(tables.contains(&"draft_attachments".to_string())); + fn table_prefix_is_short_and_deterministic() { + let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + assert_eq!(tenant_prefix(hash).unwrap(), "u_0123456789abcdef01234567__"); } #[test] - fn test_open_user_db_creates_file() { - let tmp = tempfile::tempdir().unwrap(); - let data_dir = tmp.path().to_str().unwrap(); + fn symbolic_user_hash_gets_safe_deterministic_prefix() { + let first = tenant_prefix("header-race").unwrap(); + let second = tenant_prefix("header-race").unwrap(); + assert_eq!(first, second); + assert!(first.starts_with("u_")); + assert!(first.ends_with("__")); + assert_eq!(first.len(), 28); + assert!(first[2..26].chars().all(|c| c.is_ascii_hexdigit())); + } - let conn = open_user_db(data_dir, "abc123").unwrap(); + #[test] + fn tenant_rewriter_skips_string_literals() { + let sql = prefix_tenant_tables("SELECT * FROM contacts WHERE name='contacts'", "u_test__"); + assert_eq!(sql, "SELECT * FROM u_test__contacts WHERE name='contacts'"); + } - let db_file = tmp.path().join("abc123").join("db.sqlite"); - assert!(db_file.exists()); + #[test] + fn tenant_rewriter_does_not_prefix_same_named_columns() { + let sql = prefix_tenant_tables( + "SELECT folders, contacts.email FROM notification_preferences JOIN contacts ON 1=1", + "u_test__", + ); + assert_eq!( + sql, + "SELECT folders, u_test__contacts.email FROM u_test__notification_preferences JOIN u_test__contacts ON 1=1" + ); + } - let fk: i32 = conn - .query_row(crate::queries::sqlite::queries::db_pool::Q_006, [], |row| { - row.get(0) - }) + #[test] + fn test_schema_contains_sessions_and_user_tables() { + let conn = open_test_db(); + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .filter_map(Result::ok) + .collect(); + assert!(tables.contains(&"sessions".to_string())); + assert!(tables.iter().any(|name| name.ends_with("__contacts"))); + assert!(tables.iter().any(|name| name.ends_with("__messages"))); + } + + #[test] + fn test_connection_scopes_queries() { + let conn = open_test_db(); + conn.execute( + crate::dbq!(db_trusted_senders::Q_002), + params!["alice@example.com"], + ) + .unwrap(); + let value: String = conn + .query_row("SELECT email FROM trusted_senders", [], |row| row.get(0)) .unwrap(); - assert_eq!(fk, 1); + assert_eq!(value, "alice@example.com"); + } - drop(conn); - } -} - -#[cfg(test)] -mod async_tests { - use super::*; - - #[tokio::test(flavor = "current_thread")] - async fn database_jobs_leave_runtime_free() { - let directory = tempfile::tempdir().unwrap(); - let database = UserDb::new(directory.path().to_str().unwrap(), "test"); - let (started, ready) = tokio::sync::oneshot::channel(); - let (release, wait) = std::sync::mpsc::channel(); - let job = tokio::spawn(async move { - database - .run(move |_| { - started.send(()).unwrap(); - wait.recv_timeout(std::time::Duration::from_secs(2)) - .map_err(|e| format!("runtime could not release database job: {e}"))?; - Ok(()) - }) - .await - }); - ready.await.unwrap(); - - release.send(()).unwrap(); - job.await.unwrap().unwrap(); + #[test] + fn mysql_numbered_placeholders_are_reordered_and_duplicated() { + let params = vec![DbValue::Text(Some("needle".into())), DbValue::I64(Some(25))]; + let (sql, reordered) = adapt_sql_params( + DatabaseBackend::Mysql, + "SELECT ?1, ?2, ?1".to_string(), + params, + ); + assert_eq!(sql, "SELECT ?, ?, ?"); + assert_eq!( + reordered, + vec![ + DbValue::Text(Some("needle".into())), + DbValue::I64(Some(25)), + DbValue::Text(Some("needle".into())), + ] + ); + } + + #[test] + fn pgsql_numbered_placeholders_preserve_parameter_numbers() { + let params = vec![DbValue::Text(Some("needle".into())), DbValue::I64(Some(25))]; + let (sql, unchanged) = adapt_sql_params( + DatabaseBackend::Pgsql, + "SELECT ?2, ?1, ?2".to_string(), + params.clone(), + ); + assert_eq!(sql, "SELECT $2, $1, $2"); + assert_eq!(unchanged, params); + } + + #[test] + fn tenant_and_import_table_sets_match() { + let mut tenant = TENANT_TABLES.to_vec(); + let mut imported = IMPORT_TABLES.to_vec(); + tenant.sort_unstable(); + imported.sort_unstable(); + assert_eq!(tenant, imported); + } + + #[test] + fn legacy_sqlite_v046_is_imported_and_backed_up() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path(); + let user_hash = format!("{:x}", Sha256::digest(b"legacy-user")); + let legacy_dir = data_dir.join(&user_hash); + fs::create_dir_all(&legacy_dir).unwrap(); + let legacy_path = legacy_dir.join("db.sqlite"); + + { + let legacy = rusqlite::Connection::open(&legacy_path).unwrap(); + legacy + .execute_batch(&tenant_schema(SQLITE_SCHEMA, "").unwrap()) + .unwrap(); + legacy + .execute_batch(&tenant_seed_sql(DatabaseBackend::Sqlite, "")) + .unwrap(); + legacy.execute_batch("PRAGMA user_version=46;").unwrap(); + legacy.execute( + "INSERT INTO display_preferences(id, theme, accent_color) VALUES(1, 'dark', '#123456')", + [], + ).unwrap(); + legacy + .execute( + "INSERT INTO trusted_senders(email) VALUES(?1)", + ["legacy@example.com"], + ) + .unwrap(); + } + + let central_path = data_dir.join("rustbox.sqlite"); + { + let central = rusqlite::Connection::open(¢ral_path).unwrap(); + central + .execute_batch(&global_schema(SQLITE_SCHEMA).unwrap()) + .unwrap(); + } + let prefix = tenant_prefix(&user_hash).unwrap(); + ensure_sqlite_tenant( + ¢ral_path, + data_dir.to_str().unwrap(), + &user_hash, + &prefix, + ) + .unwrap(); + + let central = rusqlite::Connection::open(¢ral_path).unwrap(); + let theme: String = central + .query_row( + &format!("SELECT theme FROM {prefix}display_preferences WHERE id=1"), + [], + |row| row.get(0), + ) + .unwrap(); + let accent: String = central + .query_row( + &format!("SELECT accent_color FROM {prefix}display_preferences WHERE id=1"), + [], + |row| row.get(0), + ) + .unwrap(); + let trusted: String = central + .query_row( + &format!("SELECT email FROM {prefix}trusted_senders"), + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(theme, "dark"); + assert_eq!(accent, "#123456"); + assert_eq!(trusted, "legacy@example.com"); + assert!(!legacy_path.exists()); + assert!(legacy_backup_path(&legacy_path).exists()); } } diff --git a/src/db/response_templates.rs b/src/db/response_templates.rs index 10481c8..d7b9343 100644 --- a/src/db/response_templates.rs +++ b/src/db/response_templates.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, params}; +use crate::db::pool::{Connection, params}; use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -11,7 +11,7 @@ pub struct ResponseTemplate { pub updated_at: String, } -fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { +fn from_row(row: &crate::db::pool::Row<'_>) -> Result { Ok(ResponseTemplate { id: row.get(0)?, name: row.get(1)?, @@ -24,7 +24,7 @@ fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { pub fn list(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_response_templates::Q_001) + .prepare(crate::dbq!(db_response_templates::Q_001)) .map_err(|e| format!("Failed to prepare response templates: {e}"))?; let rows = stmt .query_map([], from_row) @@ -35,12 +35,12 @@ pub fn list(conn: &Connection) -> Result, String> { pub fn get(conn: &Connection, id: i64) -> Result, String> { match conn.query_row( - crate::queries::sqlite::queries::db_response_templates::Q_002, + crate::dbq!(db_response_templates::Q_002), params![id], from_row, ) { Ok(value) => Ok(Some(value)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get response template: {e}")), } } @@ -66,19 +66,19 @@ pub fn create( "text" | "" => "text", _ => return Err("Invalid response template format".to_string()), }; - conn.execute( - crate::queries::sqlite::queries::db_response_templates::Q_003, - params![name, body, format], - ) - .map_err(|e| { - if e.to_string().contains("UNIQUE constraint failed") { - "A response template with this name already exists".to_string() - } else { - format!("Failed to create response template: {e}") - } - })?; - get(conn, conn.last_insert_rowid())? - .ok_or_else(|| "Failed to read response template".to_string()) + let id = conn + .insert_id( + crate::dbq!(db_response_templates::Q_003), + params![name, body, format], + ) + .map_err(|e| { + if e.to_string().contains("UNIQUE constraint failed") { + "A response template with this name already exists".to_string() + } else { + format!("Failed to create response template: {e}") + } + })?; + get(conn, id)?.ok_or_else(|| "Failed to read response template".to_string()) } pub fn update( @@ -105,7 +105,7 @@ pub fn update( }; let changed = conn .execute( - crate::queries::sqlite::queries::db_response_templates::Q_004, + crate::dbq!(db_response_templates::Q_004), params![name, body, format, id], ) .map_err(|e| { @@ -123,10 +123,7 @@ pub fn update( pub fn delete(conn: &Connection, id: i64) -> Result { Ok(conn - .execute( - crate::queries::sqlite::queries::db_response_templates::Q_005, - params![id], - ) + .execute(crate::dbq!(db_response_templates::Q_005), params![id]) .map_err(|e| format!("Failed to delete response template: {e}"))? > 0) } diff --git a/src/db/saved_searches.rs b/src/db/saved_searches.rs index d35188d..150d125 100644 --- a/src/db/saved_searches.rs +++ b/src/db/saved_searches.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, params}; +use crate::db::pool::{Connection, params}; use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -10,7 +10,7 @@ pub struct SavedSearch { pub updated_at: String, } -fn row_to_saved(row: &rusqlite::Row<'_>) -> rusqlite::Result { +fn row_to_saved(row: &crate::db::pool::Row<'_>) -> Result { Ok(SavedSearch { id: row.get(0)?, name: row.get(1)?, @@ -22,7 +22,7 @@ fn row_to_saved(row: &rusqlite::Row<'_>) -> rusqlite::Result { pub fn list(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_saved_searches::Q_001) + .prepare(crate::dbq!(db_saved_searches::Q_001)) .map_err(|e| format!("Failed to prepare saved searches: {e}"))?; let rows = stmt .query_map([], row_to_saved) @@ -37,12 +37,12 @@ pub fn create(conn: &Connection, name: &str, query_string: &str) -> Result Result, String> { match conn.query_row( - crate::queries::sqlite::queries::db_saved_searches::Q_004, + crate::dbq!(db_saved_searches::Q_004), params![id], row_to_saved, ) { Ok(value) => Ok(Some(value)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get saved search: {e}")), } } pub fn delete(conn: &Connection, id: i64) -> Result { Ok(conn - .execute( - crate::queries::sqlite::queries::db_saved_searches::Q_005, - params![id], - ) + .execute(crate::dbq!(db_saved_searches::Q_005), params![id]) .map_err(|e| format!("Failed to delete saved search: {e}"))? > 0) } diff --git a/src/db/special_folders.rs b/src/db/special_folders.rs index 39242e5..b3dc312 100644 --- a/src/db/special_folders.rs +++ b/src/db/special_folders.rs @@ -1,4 +1,4 @@ -use rusqlite::{Connection, OptionalExtension, params}; +use crate::db::pool::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"]; @@ -20,11 +20,9 @@ pub fn get_role(conn: &Connection, role: &str) -> Result, String> if !valid_role(role) { return Err(format!("Invalid special folder role: {role}")); } - conn.query_row( - crate::queries::sqlite::queries::db_special_folders::Q_001, - [role], - |row| row.get(0), - ) + conn.query_row(crate::dbq!(db_special_folders::Q_001), [role], |row| { + row.get(0) + }) .optional() .map_err(|e| format!("Failed to read special folder {role}: {e}")) } @@ -46,27 +44,22 @@ pub fn set_role(conn: &Connection, role: &str, folder_name: Option<&str>) -> Res match folder_name.map(str::trim).filter(|value| !value.is_empty()) { Some(folder) => { let exists: bool = conn - .query_row( - crate::queries::sqlite::queries::db_special_folders::Q_002, - [folder], - |row| row.get(0), - ) + .query_row(crate::dbq!(db_special_folders::Q_002), [folder], |row| { + row.get(0) + }) .map_err(|e| format!("Failed to validate folder {folder}: {e}"))?; if !exists { return Err(format!("Folder does not exist: {folder}")); } conn.execute( - crate::queries::sqlite::queries::db_special_folders::Q_003, + crate::dbq!(db_special_folders::Q_003), params![role, folder], ) .map_err(|e| format!("Failed to set special folder {role}: {e}"))?; } None => { - conn.execute( - crate::queries::sqlite::queries::db_special_folders::Q_004, - [role], - ) - .map_err(|e| format!("Failed to clear special folder {role}: {e}"))?; + conn.execute(crate::dbq!(db_special_folders::Q_004), [role]) + .map_err(|e| format!("Failed to clear special folder {role}: {e}"))?; } } Ok(()) @@ -87,7 +80,7 @@ pub fn rename_folder_reference( new_name: &str, ) -> Result<(), String> { conn.execute( - crate::queries::sqlite::queries::db_special_folders::Q_005, + crate::dbq!(db_special_folders::Q_005), params![new_name, old_name], ) .map_err(|e| format!("Failed to update special folder reference: {e}"))?; @@ -95,11 +88,8 @@ pub fn rename_folder_reference( } pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_special_folders::Q_006, - [folder_name], - ) - .map_err(|e| format!("Failed to clear special folder reference: {e}"))?; + conn.execute(crate::dbq!(db_special_folders::Q_006), [folder_name]) + .map_err(|e| format!("Failed to clear special folder reference: {e}"))?; Ok(()) } diff --git a/src/db/tags.rs b/src/db/tags.rs index 461019d..e4ada1c 100644 --- a/src/db/tags.rs +++ b/src/db/tags.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use rusqlite::{Connection, params}; +use crate::db::pool::{Connection, DbValue, ToDbValue, params}; use serde::Serialize; use crate::db::messages::CachedMessage; @@ -33,7 +33,7 @@ pub fn imap_keyword_for_tag_id(id: &str) -> String { pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> { let imap_keyword = imap_keyword_for_tag_id(id); conn.execute( - crate::queries::sqlite::queries::db_tags::Q_001, + crate::dbq!(db_tags::Q_001), params![id, name, color, imap_keyword], ) .map_err(|e| format!("Failed to create tag: {e}"))?; @@ -42,7 +42,7 @@ pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul pub fn list_tags(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_tags::Q_002) + .prepare(crate::dbq!(db_tags::Q_002)) .map_err(|e| format!("Failed to prepare list_tags: {e}"))?; let rows = stmt @@ -76,7 +76,7 @@ pub fn get_message_refs_by_tag( tag_id: &str, ) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_tags::Q_003) + .prepare(crate::dbq!(db_tags::Q_003)) .map_err(|e| format!("Failed to prepare tag message refs: {e}"))?; let rows = stmt .query_map(params![tag_id], |row| Ok((row.get(0)?, row.get(1)?))) @@ -146,17 +146,14 @@ pub fn reconcile_imap_keywords( pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result { let updated = conn - .execute( - crate::queries::sqlite::queries::db_tags::Q_004, - params![name, color, id], - ) + .execute(crate::dbq!(db_tags::Q_004), params![name, color, id]) .map_err(|e| format!("Failed to update tag: {e}"))?; Ok(updated > 0) } pub fn delete_tag(conn: &Connection, id: &str) -> Result { let deleted = conn - .execute(crate::queries::sqlite::queries::db_tags::Q_005, params![id]) + .execute(crate::dbq!(db_tags::Q_005), params![id]) .map_err(|e| format!("Failed to delete tag: {e}"))?; Ok(deleted > 0) } @@ -167,11 +164,8 @@ pub fn add_tag_to_message( uid: u32, folder: &str, ) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_tags::Q_006, - params![tag_id, uid, folder], - ) - .map_err(|e| format!("Failed to add tag to message: {e}"))?; + conn.execute(crate::dbq!(db_tags::Q_006), params![tag_id, uid, folder]) + .map_err(|e| format!("Failed to add tag to message: {e}"))?; Ok(()) } @@ -182,10 +176,7 @@ pub fn remove_tag_from_message( folder: &str, ) -> Result { let deleted = conn - .execute( - crate::queries::sqlite::queries::db_tags::Q_007, - params![tag_id, uid, folder], - ) + .execute(crate::dbq!(db_tags::Q_007), params![tag_id, uid, folder]) .map_err(|e| format!("Failed to remove tag from message: {e}"))?; Ok(deleted > 0) } @@ -196,7 +187,7 @@ pub fn get_message_tags( folder: &str, ) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_tags::Q_008) + .prepare(crate::dbq!(db_tags::Q_008)) .map_err(|e| format!("Failed to prepare get_message_tags: {e}"))?; let rows = stmt @@ -236,22 +227,22 @@ pub fn get_tags_for_messages( }) .collect(); - let sql = crate::rustbox_query_db_tags_001!(conditions.join(" OR ")); + let sql = format!( + "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color FROM message_tags mt JOIN tags t ON t.id = mt.tag_id WHERE {} ORDER BY t.name ASC", + conditions.join(" OR ") + ); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare get_tags_for_messages: {e}"))?; - let mut param_values: Vec> = Vec::new(); + let mut param_values: Vec = Vec::new(); for &(uid, folder) in messages { - param_values.push(Box::new(uid)); - param_values.push(Box::new(folder.to_string())); + param_values.push(uid.to_db_value()); + param_values.push(folder.to_db_value()); } - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|p| p.as_ref()).collect(); - let rows = stmt - .query_map(params_ref.as_slice(), |row| { + .query_map(param_values, |row| { let uid: u32 = row.get(0)?; let folder: String = row.get(1)?; let tag = MessageTag { @@ -279,7 +270,7 @@ pub fn get_messages_by_tag( ) -> Result, String> { let offset = page * per_page; let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_tags::Q_009) + .prepare(crate::dbq!(db_tags::Q_009)) .map_err(|e| format!("Failed to prepare get_messages_by_tag: {e}"))?; let rows = stmt @@ -314,11 +305,9 @@ pub fn get_messages_by_tag( } pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result { - conn.query_row( - crate::queries::sqlite::queries::db_tags::Q_010, - params![tag_id], - |row| row.get(0), - ) + conn.query_row(crate::dbq!(db_tags::Q_010), params![tag_id], |row| { + row.get(0) + }) .map_err(|e| format!("Failed to count messages by tag: {e}")) } @@ -328,16 +317,10 @@ mod tests { use crate::db::pool::open_test_db; fn insert_test_message(conn: &Connection, folder: &str, uid: u32) { - conn.execute( - crate::queries::sqlite::queries::db_tags::Q_011, - params![folder], - ) - .unwrap(); - conn.execute( - crate::queries::sqlite::queries::db_tags::Q_012, - params![uid, folder], - ) - .unwrap(); + conn.execute(crate::dbq!(db_tags::Q_011), params![folder]) + .unwrap(); + conn.execute(crate::dbq!(db_tags::Q_012), params![uid, folder]) + .unwrap(); } #[test] diff --git a/src/db/trusted_senders.rs b/src/db/trusted_senders.rs index 77f2901..656d2f0 100644 --- a/src/db/trusted_senders.rs +++ b/src/db/trusted_senders.rs @@ -1,8 +1,8 @@ -use rusqlite::Connection; +use crate::db::pool::Connection; pub fn list(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare(crate::queries::sqlite::queries::db_trusted_senders::Q_001) + .prepare(crate::dbq!(db_trusted_senders::Q_001)) .map_err(|e| format!("Failed to prepare trusted sender list: {e}"))?; let rows = stmt .query_map([], |row| row.get::<_, String>(0)) @@ -12,20 +12,14 @@ pub fn list(conn: &Connection) -> Result, String> { } pub fn add(conn: &Connection, email: &str) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_trusted_senders::Q_002, - [email], - ) - .map_err(|e| format!("Failed to trust sender: {e}"))?; + conn.execute(crate::dbq!(db_trusted_senders::Q_002), [email]) + .map_err(|e| format!("Failed to trust sender: {e}"))?; Ok(()) } pub fn remove(conn: &Connection, email: &str) -> Result<(), String> { - conn.execute( - crate::queries::sqlite::queries::db_trusted_senders::Q_003, - [email], - ) - .map_err(|e| format!("Failed to remove trusted sender: {e}"))?; + conn.execute(crate::dbq!(db_trusted_senders::Q_003), [email]) + .map_err(|e| format!("Failed to remove trusted sender: {e}"))?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 569300e..5713371 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,9 @@ async fn main() -> Result<(), Box> { format!("invalid RustBox configuration: {message}"), ) })?; + db::pool::configure(&loaded_config) + .await + .map_err(std::io::Error::other)?; let config = Arc::new(loaded_config); let persistence = session_store::from_config(&config) diff --git a/src/pgp/discovery.rs b/src/pgp/discovery.rs index a15a5ad..1c0a3de 100644 --- a/src/pgp/discovery.rs +++ b/src/pgp/discovery.rs @@ -126,7 +126,7 @@ async fn https_get_address( format!("{host}:{port}") }; let request = format!( - "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.1.0\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n" + "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.1.1\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n" ); timeout(duration, stream.write_all(request.as_bytes())) .await diff --git a/src/queries/mysql/queries.rs b/src/queries/mysql/queries.rs index a2eec38..b8f93fe 100644 --- a/src/queries/mysql/queries.rs +++ b/src/queries/mysql/queries.rs @@ -11,7 +11,7 @@ pub mod db_calendar { working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \ caldav_username, caldav_enabled, updated_at"; - pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP"; + pub const Q_001: &str = "SELECT CAST(CURRENT_TIMESTAMP AS CHAR)"; pub const Q_002: &str = "INSERT INTO calendar_events (id, title, description, location, start_time, end_time, all_day, recurrence_rule, attendees, organizer, status, source, source_uid, @@ -45,7 +45,7 @@ pub mod db_calendar { pub mod db_contact_groups { pub const Q_001: &str = "INSERT INTO contact_groups (id, name) VALUES (?, ?)"; pub const Q_002: &str = "SELECT g.id, g.name, g.created_at, g.updated_at, - (SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count + (SELECT CAST(COUNT(*) AS SIGNED) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count FROM contact_groups g ORDER BY g.name ASC"; pub const Q_003: &str = @@ -62,7 +62,7 @@ pub mod db_contact_groups { WHERE m.group_id = ? ORDER BY c.name ASC"; pub const Q_008: &str = "SELECT g.id, g.name, g.created_at, g.updated_at, - (SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count + (SELECT CAST(COUNT(*) AS SIGNED) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count FROM contact_groups g JOIN contact_group_members m ON m.group_id = g.id WHERE m.contact_id = ? @@ -75,29 +75,30 @@ pub mod db_contacts { (id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes, - phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday, - is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count, - source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json, - phones_json=excluded.phones_json, addresses_json=excluded.addresses_json"; + ON DUPLICATE KEY UPDATE + email=VALUES(email), name=VALUES(name), company=VALUES(company), notes=VALUES(notes), + phone=VALUES(phone), job_title=VALUES(job_title), address=VALUES(address), website=VALUES(website), birthday=VALUES(birthday), + is_favorite=VALUES(is_favorite), last_contacted=VALUES(last_contacted), contact_count=VALUES(contact_count), + source=VALUES(source), updated_at=VALUES(updated_at), emails_json=VALUES(emails_json), + phones_json=VALUES(phones_json), addresses_json=VALUES(addresses_json)"; pub const Q_002: &str = "DELETE FROM contacts WHERE id = ?"; pub const Q_003: &str = r#" SELECT email, name FROM known_addresses - WHERE (email LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\') + WHERE (email LIKE ?1 ESCAPE '!' OR name LIKE ?1 ESCAPE '!') AND email NOT IN (SELECT email FROM contacts) AND NOT EXISTS ( - SELECT 1 FROM contacts c, json_each(c.emails_json) alt + SELECT 1 FROM contacts c + JOIN JSON_TABLE(COALESCE(NULLIF(c.emails_json, ''), '[]'), '$[*]' COLUMNS(value VARCHAR(320) PATH '$')) alt WHERE lower(alt.value) = lower(known_addresses.email) ) ORDER BY email ASC - LIMIT ? + LIMIT ?2 "#; pub const Q_004: &str = "INSERT INTO known_addresses (email, name) VALUES (?, ?) - ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''"; + ON DUPLICATE KEY UPDATE name = IF(VALUES(name) <> '', VALUES(name), name)"; pub const Q_005: &str = "INSERT INTO known_addresses (email, name) VALUES (?, ?) - ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''"; + ON DUPLICATE KEY UPDATE name = IF(VALUES(name) <> '', VALUES(name), name)"; pub const Q_006: &str = "UPDATE contacts SET contact_count = contact_count + 1, last_contacted = CURRENT_TIMESTAMP, @@ -105,24 +106,24 @@ pub mod db_contacts { WHERE email = ?"; pub const Q_007: &str = "INSERT IGNORE INTO contacts (id, email, name, source) VALUES (?, ?, ?, 'auto')"; - pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=CURRENT_TIMESTAMP"; + pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE content_type=VALUES(content_type), data=VALUES(data), updated_at=CURRENT_TIMESTAMP"; pub const Q_009: &str = "SELECT content_type, data FROM contact_photos WHERE contact_id = ?"; pub const Q_010: &str = "DELETE FROM contact_photos WHERE contact_id = ?"; pub const FORMAT_001: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?"; pub const FORMAT_002: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts \ - WHERE lower(email) = lower(?) \ - OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?))"; + WHERE lower(email) = lower(?1) \ + OR EXISTS (SELECT 1 FROM JSON_TABLE(COALESCE(NULLIF(contacts.emails_json, ''), '[]'), '$[*]' COLUMNS(value VARCHAR(320) PATH '$')) alt WHERE lower(alt.value) = lower(?1))"; pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR emails_json LIKE ? ESCAPE '\\' + WHERE name LIKE ?1 ESCAPE '!' OR email LIKE ?1 ESCAPE '!' OR emails_json LIKE ?1 ESCAPE '!' ORDER BY name ASC - LIMIT ? OFFSET ?"; + LIMIT ?2 OFFSET ?3"; pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?"; pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR emails_json LIKE ? ESCAPE '\\' + WHERE name LIKE ?1 ESCAPE '!' OR email LIKE ?1 ESCAPE '!' OR emails_json LIKE ?1 ESCAPE '!' ORDER BY name ASC - LIMIT ?"; + LIMIT ?2"; } pub mod db_display_preferences { @@ -134,29 +135,28 @@ pub mod db_display_preferences { pub mod db_drafts { pub const Q_001: &str = "INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(id) DO UPDATE SET - to_addresses = excluded.to_addresses, - cc_addresses = excluded.cc_addresses, - bcc_addresses = excluded.bcc_addresses, - subject = excluded.subject, - text_body = excluded.text_body, - html_body = excluded.html_body, - in_reply_to = excluded.in_reply_to, - references_header = excluded.references_header, - request_mdn = excluded.request_mdn, - priority = excluded.priority, - compose_format = excluded.compose_format, - pgp_mode = excluded.pgp_mode, - pgp_inline = excluded.pgp_inline, - pgp_attach_public_key = excluded.pgp_attach_public_key, - pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys, + ON DUPLICATE KEY UPDATE + to_addresses = VALUES(to_addresses), + cc_addresses = VALUES(cc_addresses), + bcc_addresses = VALUES(bcc_addresses), + subject = VALUES(subject), + text_body = VALUES(text_body), + html_body = VALUES(html_body), + in_reply_to = VALUES(in_reply_to), + references_header = VALUES(references_header), + request_mdn = VALUES(request_mdn), + priority = VALUES(priority), + compose_format = VALUES(compose_format), + pgp_mode = VALUES(pgp_mode), + pgp_inline = VALUES(pgp_inline), + pgp_attach_public_key = VALUES(pgp_attach_public_key), + pgp_attach_recipient_keys = VALUES(pgp_attach_recipient_keys), updated_at = CURRENT_TIMESTAMP"; pub const Q_002: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at FROM drafts WHERE id = ?"; pub const Q_003: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at FROM drafts ORDER BY updated_at DESC"; - pub const Q_004: &str = - "UPDATE drafts SET remote_folder = ?, remote_uid = ?, remote_uid_validity = ? WHERE id = ?"; + pub const Q_004: &str = "UPDATE drafts SET remote_folder = ?2, remote_uid = ?3, remote_uid_validity = ?4 WHERE id = ?1"; pub const Q_005: &str = "DELETE FROM drafts WHERE id = ?"; pub const Q_006: &str = "INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path) @@ -173,7 +173,7 @@ pub mod db_folder_preferences { pub const Q_003: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)"; pub const Q_004: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1"; pub const Q_005: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)"; - pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = CURRENT_TIMESTAMP"; + pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)\n ON DUPLICATE KEY UPDATE flagged_on_top = VALUES(flagged_on_top), updated_at = CURRENT_TIMESTAMP"; pub const Q_007: &str = "DELETE FROM folder_preferences WHERE folder_name = ?"; } @@ -196,17 +196,16 @@ pub mod db_folders { pub const Q_007: &str = "UPDATE folders SET uid_validity = ?, total_count = ?, highest_modseq = ? WHERE name = ?"; - pub const Q_008: &str = - "SELECT COUNT(*) FROM messages WHERE folder = ? AND flags NOT LIKE '%\\Seen%'"; + pub const Q_008: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM messages WHERE folder = ? AND LOCATE(CONCAT(CHAR(92), 'Seen'), flags) = 0"; pub const Q_009: &str = "UPDATE folders SET unread_count = ? WHERE name = ?"; pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS SIGNED) + ?) WHERE name = ?"; pub const Q_011: &str = "SELECT EXISTS( SELECT 1 FROM folders - WHERE updated_at > datetime('now', ?) + WHERE updated_at > DATE_FORMAT(DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND), '%Y-%m-%d %H:%i:%s') )"; pub const Q_012: &str = "SELECT EXISTS( SELECT 1 FROM folders - WHERE name = ? AND messages_updated_at > datetime('now', ?) + WHERE name = ? AND messages_updated_at > DATE_FORMAT(DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND), '%Y-%m-%d %H:%i:%s') )"; pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?"; pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?"; @@ -216,7 +215,7 @@ pub mod db_folders { WHERE name = ?"; pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?"; pub const Q_017: &str = "DELETE FROM folders WHERE name = ?"; - pub const Q_018: &str = "PRAGMA defer_foreign_keys = ON;"; + pub const Q_018: &str = "SELECT 1"; pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at) SELECT ?, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP, messages_updated_at FROM folders WHERE name = ?"; @@ -237,14 +236,14 @@ pub mod db_header_sync { pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'"; pub const Q_002: &str = "INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1') - ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1"; + ON DUPLICATE KEY UPDATE value = CAST(value AS SIGNED) + 1"; } pub mod db_identities { pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default"; pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; pub const Q_002: &str = "DELETE FROM identities WHERE id = ?"; - pub const Q_003: &str = "SELECT COUNT(*) FROM identities"; + pub const Q_003: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM identities"; pub const Q_004: &str = "UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE is_default = 1"; pub const FORMAT_001: &str = @@ -273,8 +272,8 @@ pub mod db_messages { pub const Q_004: &str = "SELECT uid, folder, subject, from_address, from_name, to_addresses, \ COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \ FROM messages ORDER BY folder, uid"; - pub const Q_005: &str = "SELECT COUNT(*) FROM messages WHERE folder = ?"; - pub const Q_006: &str = "SELECT COUNT(*) FROM messages"; + pub const Q_005: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM messages WHERE folder = ?"; + pub const Q_006: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM messages"; pub const Q_007: &str = "UPDATE messages SET flags = ? WHERE folder = ? AND uid = ?"; pub const Q_008: &str = "UPDATE messages SET body_html = ?, body_text = ?, body_cached = 1, @@ -291,7 +290,7 @@ pub mod db_messages { ORDER BY date_epoch DESC LIMIT ?"; pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?"; - pub const Q_014: &str = "SELECT SUM(size) FROM messages"; + pub const Q_014: &str = "SELECT CAST(SUM(size) AS SIGNED) FROM messages"; pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS} FROM messages WHERE folder = ? @@ -307,9 +306,9 @@ pub mod db_messages { WHERE folder = ? AND uid = ?"; pub const FORMAT_004: &str = "SELECT {MSG_SELECT_COLS} FROM messages - WHERE message_id = ? - OR in_reply_to = ? - OR references_header LIKE ? ESCAPE '\\' + WHERE message_id = ?1 + OR in_reply_to = ?1 + OR references_header LIKE ?2 ESCAPE '!' ORDER BY date_epoch ASC"; pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS} FROM messages @@ -326,9 +325,9 @@ pub mod db_notification_preferences { pub mod db_pgp { pub const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at"; - pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?,?,?,?,?,?,?,?,?)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=CURRENT_TIMESTAMP"; + pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?,?,?,?,?,?,?,?,?)\n ON DUPLICATE KEY UPDATE key_id=VALUES(key_id),email=CASE WHEN VALUES(email)='' THEN email ELSE VALUES(email) END,user_id=CASE WHEN VALUES(user_id)='' THEN user_id ELSE VALUES(user_id) END,public_key_armor=VALUES(public_key_armor),secret_key_path=COALESCE(VALUES(secret_key_path),secret_key_path),has_secret=GREATEST(has_secret,VALUES(has_secret)),trust=VALUES(trust),source=VALUES(source),updated_at=CURRENT_TIMESTAMP"; pub const Q_002: &str = "DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?"; - pub const Q_003: &str = "UPDATE pgp_keys SET trust=?,updated_at=CURRENT_TIMESTAMP WHERE replace(upper(fingerprint),' ','')=?"; + pub const Q_003: &str = "UPDATE pgp_keys SET trust=?2,updated_at=CURRENT_TIMESTAMP WHERE replace(upper(fingerprint),' ','')=?1"; pub const Q_004: &str = "SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1"; pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=?,hkp_enabled=?,require_trusted_encryption=?,updated_at=CURRENT_TIMESTAMP WHERE id=1"; @@ -343,11 +342,12 @@ pub mod db_pgp { pub mod db_pool { pub const Q_001: &str = "SELECT 0"; - pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP"; + pub const Q_002: &str = "SELECT CAST(CURRENT_TIMESTAMP AS CHAR)"; pub const Q_003: &str = "SELECT 1"; pub const Q_004: &str = "SELECT 1"; pub const Q_005: &str = "SHOW TABLES"; pub const Q_006: &str = "SELECT 1"; + pub const Q_007: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM information_schema.tables WHERE table_schema = DATABASE()"; } pub mod db_response_templates { @@ -374,7 +374,7 @@ pub mod db_saved_searches { pub mod db_special_folders { pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = ?"; pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)"; - pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = CURRENT_TIMESTAMP"; + pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)\n ON DUPLICATE KEY UPDATE folder_name = VALUES(folder_name), updated_at = CURRENT_TIMESTAMP"; pub const Q_004: &str = "DELETE FROM special_folders WHERE role = ?"; pub const Q_005: &str = "UPDATE special_folders SET folder_name = ?, updated_at = CURRENT_TIMESTAMP WHERE folder_name = ?"; pub const Q_006: &str = "DELETE FROM special_folders WHERE folder_name = ?"; @@ -384,7 +384,7 @@ pub mod db_tags { pub const Q_001: &str = "INSERT INTO tags (id, name, color, imap_keyword) VALUES (?, ?, ?, ?)"; pub const Q_002: &str = "SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at, - (SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count + (SELECT CAST(COUNT(*) AS SIGNED) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count FROM tags t ORDER BY t.name ASC"; pub const Q_003: &str = "SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?"; @@ -409,7 +409,7 @@ pub mod db_tags { WHERE mt.tag_id = ? ORDER BY m.date_epoch DESC LIMIT ? OFFSET ?"; - pub const Q_010: &str = "SELECT COUNT(*) FROM message_tags WHERE tag_id = ?"; + pub const Q_010: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM message_tags WHERE tag_id = ?"; pub const Q_011: &str = "INSERT IGNORE INTO folders (name) VALUES (?)"; pub const Q_012: &str = "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet) VALUES (?, ?, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')"; @@ -427,23 +427,23 @@ pub mod db_trusted_senders { } pub mod routes_contacts { - pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP"; - pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP"; - pub const Q_003: &str = "SELECT CURRENT_TIMESTAMP"; + pub const Q_001: &str = "SELECT CAST(CURRENT_TIMESTAMP AS CHAR)"; + pub const Q_002: &str = "SELECT CAST(CURRENT_TIMESTAMP AS CHAR)"; + pub const Q_003: &str = "SELECT CAST(CURRENT_TIMESTAMP AS CHAR)"; } pub mod routes_diagnostics { - pub const Q_001: &str = "SELECT COUNT(*), - COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0), - CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END, - CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END + pub const Q_001: &str = "SELECT CAST(COUNT(*) AS SIGNED), + CAST(COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0) AS SIGNED), + CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE CONCAT(replace(MAX(updated_at), ' ', 'T'), 'Z') END, + CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE CONCAT(replace(MAX(messages_updated_at), ' ', 'T'), 'Z') END FROM folders"; - pub const Q_002: &str = "SELECT COUNT(*) FROM messages"; - pub const Q_003: &str = "SELECT COUNT(*) FROM messages WHERE body_cached = 1"; + pub const Q_002: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM messages"; + pub const Q_003: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM messages WHERE body_cached = 1"; } pub mod routes_folder_mgmt { - pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ? ELSE flags || ',' || ? END WHERE folder = ? AND instr(flags, ?) = 0"; + pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE CONCAT(flags, ',', ?2) END WHERE folder = ?1 AND LOCATE(?2, flags) = 0"; pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = ?"; pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?"; pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?"; @@ -479,7 +479,8 @@ pub mod sessions { "SELECT * FROM sessions WHERE browser_id = ? ORDER BY created_at_unix"; pub const BY_USER: &str = "SELECT * FROM sessions WHERE user_hash = ? ORDER BY last_accessed_unix DESC"; - pub const BROWSER_EXISTS: &str = "SELECT COUNT(*) FROM sessions WHERE browser_id = ?"; + pub const BROWSER_EXISTS: &str = + "SELECT CAST(COUNT(*) AS SIGNED) FROM sessions WHERE browser_id = ?"; pub const DELETE_ACCOUNT: &str = "DELETE FROM sessions WHERE account_id = ?"; pub const DELETE_BROWSER: &str = "DELETE FROM sessions WHERE browser_id = ?"; pub const DELETE_EXPIRED: &str = diff --git a/src/queries/pgsql/queries.rs b/src/queries/pgsql/queries.rs index a404914..36ec12a 100644 --- a/src/queries/pgsql/queries.rs +++ b/src/queries/pgsql/queries.rs @@ -10,7 +10,7 @@ pub mod db_calendar { pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \ working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \ caldav_username, caldav_enabled, updated_at"; - pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP"; + pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP::text"; pub const Q_002: &str = "INSERT INTO calendar_events (id, title, description, location, start_time, end_time, all_day, recurrence_rule, attendees, organizer, status, source, source_uid, @@ -18,8 +18,10 @@ pub mod db_calendar { reminder_minutes, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)"; pub const Q_003: &str = "DELETE FROM calendar_events WHERE id = $1"; - pub const Q_004: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)"; - pub const Q_005: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)"; + pub const Q_004: &str = + "INSERT INTO calendar_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING"; + pub const Q_005: &str = + "INSERT INTO calendar_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING"; pub const Q_006: &str = "SELECT id, name, url_template, icon, is_default, created_at FROM meeting_templates ORDER BY is_default DESC, name ASC"; pub const Q_007: &str = @@ -48,10 +50,9 @@ pub mod db_contact_groups { FROM contact_groups g ORDER BY g.name ASC"; pub const Q_003: &str = - "UPDATE contact_groups SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2"; + "UPDATE contact_groups SET name = $1, updated_at = CURRENT_TIMESTAMP::text WHERE id = $2"; pub const Q_004: &str = "DELETE FROM contact_groups WHERE id = $1"; - pub const Q_005: &str = - "INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES ($1, $2)"; + pub const Q_005: &str = "INSERT INTO contact_group_members (group_id, contact_id) VALUES ($1, $2) ON CONFLICT (group_id, contact_id) DO NOTHING"; pub const Q_006: &str = "DELETE FROM contact_group_members WHERE group_id = $1 AND contact_id = $2"; pub const Q_007: &str = "SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite, @@ -84,10 +85,11 @@ pub mod db_contacts { pub const Q_003: &str = r#" SELECT email, name FROM known_addresses - WHERE (email LIKE $1 ESCAPE '\' OR name LIKE $1 ESCAPE '\') + WHERE (email LIKE $1 ESCAPE '!' OR name LIKE $1 ESCAPE '!') AND email NOT IN (SELECT email FROM contacts) AND NOT EXISTS ( - SELECT 1 FROM contacts c, json_each(c.emails_json) alt + SELECT 1 FROM contacts c + CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(NULLIF(c.emails_json, ''), '[]')::jsonb) AS alt(value) WHERE lower(alt.value) = lower(known_addresses.email) ) ORDER BY email ASC @@ -99,40 +101,41 @@ pub mod db_contacts { ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''"; pub const Q_006: &str = "UPDATE contacts SET contact_count = contact_count + 1, - last_contacted = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP + last_contacted = CURRENT_TIMESTAMP::text, + updated_at = CURRENT_TIMESTAMP::text WHERE email = $1"; - pub const Q_007: &str = "INSERT OR IGNORE INTO contacts (id, email, name, source) - VALUES ($1, $2, $3, 'auto')"; - pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=CURRENT_TIMESTAMP"; + pub const Q_007: &str = "INSERT INTO contacts (id, email, name, source) + VALUES ($1, $2, $3, 'auto') ON CONFLICT (id) DO NOTHING"; + pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP::text) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=CURRENT_TIMESTAMP::text"; pub const Q_009: &str = "SELECT content_type, data FROM contact_photos WHERE contact_id = $1"; pub const Q_010: &str = "DELETE FROM contact_photos WHERE contact_id = $1"; pub const FORMAT_001: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = $1"; pub const FORMAT_002: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts \ WHERE lower(email) = lower($1) \ - OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower($1))"; + OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(NULLIF(contacts.emails_json, ''), '[]')::jsonb) AS alt(value) WHERE lower(alt.value) = lower($1))"; pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE $1 ESCAPE '\\' OR email LIKE $1 ESCAPE '\\' OR emails_json LIKE $1 ESCAPE '\\' + WHERE name LIKE $1 ESCAPE '!' OR email LIKE $1 ESCAPE '!' OR emails_json LIKE $1 ESCAPE '!' ORDER BY name ASC LIMIT $2 OFFSET $3"; pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts ORDER BY name ASC LIMIT $1 OFFSET $2"; pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE $1 ESCAPE '\\' OR email LIKE $1 ESCAPE '\\' OR emails_json LIKE $1 ESCAPE '\\' + WHERE name LIKE $1 ESCAPE '!' OR email LIKE $1 ESCAPE '!' OR emails_json LIKE $1 ESCAPE '!' ORDER BY name ASC LIMIT $2"; } pub mod db_display_preferences { pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1"; - pub const Q_002: &str = "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)"; + pub const Q_002: &str = + "INSERT INTO display_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING"; pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ${}"; } pub mod db_drafts { pub const Q_001: &str = "INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP::text) ON CONFLICT(id) DO UPDATE SET to_addresses = excluded.to_addresses, cc_addresses = excluded.cc_addresses, @@ -149,7 +152,7 @@ pub mod db_drafts { pgp_inline = excluded.pgp_inline, pgp_attach_public_key = excluded.pgp_attach_public_key, pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys, - updated_at = CURRENT_TIMESTAMP"; + updated_at = CURRENT_TIMESTAMP::text"; pub const Q_002: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at FROM drafts WHERE id = $1"; pub const Q_003: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at @@ -171,40 +174,43 @@ pub mod db_folder_preferences { pub const Q_003: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)"; pub const Q_004: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1"; pub const Q_005: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)"; - pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES($1, $2, CURRENT_TIMESTAMP)\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = CURRENT_TIMESTAMP"; + pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES($1, $2, CURRENT_TIMESTAMP::text)\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = CURRENT_TIMESTAMP::text"; pub const Q_007: &str = "DELETE FROM folder_preferences WHERE folder_name = $1"; } pub mod db_folders { - pub const Q_001: &str = "INSERT OR REPLACE INTO folders + pub const Q_001: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP)"; - pub const Q_002: &str = "INSERT OR IGNORE INTO folders + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text) + ON CONFLICT (name) DO UPDATE SET delimiter=excluded.delimiter, parent=excluded.parent, flags=excluded.flags, + is_subscribed=excluded.is_subscribed, total_count=excluded.total_count, unread_count=excluded.unread_count, + uid_validity=excluded.uid_validity, highest_modseq=excluded.highest_modseq, updated_at=CURRENT_TIMESTAMP::text"; + pub const Q_002: &str = "INSERT INTO folders (name, delimiter, flags, is_subscribed, updated_at) - VALUES ($1, $2, $3, 1, CURRENT_TIMESTAMP)"; + VALUES ($1, $2, $3, 1, CURRENT_TIMESTAMP::text) ON CONFLICT (name) DO NOTHING"; pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq FROM folders ORDER BY name"; pub const Q_004: &str = "DELETE FROM folders"; - pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP"; - pub const Q_006: &str = "UPDATE folders SET uid_validity = $1, total_count = $2, messages_updated_at = CURRENT_TIMESTAMP + pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP::text"; + pub const Q_006: &str = "UPDATE folders SET uid_validity = $1, total_count = $2, messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $3"; pub const Q_007: &str = "UPDATE folders SET uid_validity = $1, total_count = $2, highest_modseq = $3 WHERE name = $4"; pub const Q_008: &str = - "SELECT COUNT(*) FROM messages WHERE folder = $1 AND flags NOT LIKE '%\\Seen%'"; + "SELECT COUNT(*) FROM messages WHERE folder = $1 AND strpos(flags, chr(92) || 'Seen') = 0"; pub const Q_009: &str = "UPDATE folders SET unread_count = $1 WHERE name = $2"; pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS INTEGER) + $1) WHERE name = $2"; pub const Q_011: &str = "SELECT EXISTS( SELECT 1 FROM folders - WHERE updated_at > datetime('now', $1) + WHERE updated_at > to_char(CURRENT_TIMESTAMP - ($1 * INTERVAL '1 second'), 'YYYY-MM-DD HH24:MI:SS') )"; pub const Q_012: &str = "SELECT EXISTS( SELECT 1 FROM folders - WHERE name = $1 AND messages_updated_at > datetime('now', $2) + WHERE name = $1 AND messages_updated_at > to_char(CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second'), 'YYYY-MM-DD HH24:MI:SS') )"; pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = $1"; pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = $1"; @@ -214,9 +220,9 @@ pub mod db_folders { WHERE name = $1"; pub const Q_016: &str = "DELETE FROM messages WHERE folder = $1"; pub const Q_017: &str = "DELETE FROM folders WHERE name = $1"; - pub const Q_018: &str = "PRAGMA defer_foreign_keys = ON;"; + pub const Q_018: &str = "SELECT 1"; pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at) - SELECT $1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP, messages_updated_at + SELECT $1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP::text, messages_updated_at FROM folders WHERE name = $2"; pub const Q_020: &str = "UPDATE messages SET folder = $1 WHERE folder = $2"; pub const Q_021: &str = "UPDATE message_tags SET message_folder = $1 WHERE message_folder = $2"; @@ -243,8 +249,7 @@ pub mod db_identities { pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)"; pub const Q_002: &str = "DELETE FROM identities WHERE id = $1"; pub const Q_003: &str = "SELECT COUNT(*) FROM identities"; - pub const Q_004: &str = - "UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE is_default = 1"; + pub const Q_004: &str = "UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP::text WHERE is_default = 1"; pub const FORMAT_001: &str = "SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC"; pub const FORMAT_002: &str = "SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = $1"; @@ -255,17 +260,23 @@ pub mod db_identities { pub mod db_mail_preferences { pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1"; - pub const Q_002: &str = "INSERT OR IGNORE INTO mail_preferences (id) VALUES (1)"; - pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, updated_at = CURRENT_TIMESTAMP WHERE id = 1"; + pub const Q_002: &str = + "INSERT INTO mail_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING"; + pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1"; } pub mod db_messages { pub const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header, subject, from_address, from_name, to_addresses, cc_addresses, date, flags, size, has_attachments, snippet, reaction"; - pub const Q_001: &str = "INSERT OR REPLACE INTO messages + pub const Q_001: &str = "INSERT INTO messages (uid, folder, message_id, in_reply_to, references_header, subject, from_address, from_name, to_addresses, cc_addresses, date, flags, size, has_attachments, snippet, date_epoch, reaction) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)"; + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ON CONFLICT (uid, folder) DO UPDATE SET message_id=excluded.message_id, in_reply_to=excluded.in_reply_to, + references_header=excluded.references_header, subject=excluded.subject, from_address=excluded.from_address, + from_name=excluded.from_name, to_addresses=excluded.to_addresses, cc_addresses=excluded.cc_addresses, + date=excluded.date, flags=excluded.flags, size=excluded.size, has_attachments=excluded.has_attachments, + snippet=excluded.snippet, date_epoch=excluded.date_epoch, reaction=excluded.reaction"; pub const Q_002: &str = "DELETE FROM messages WHERE folder = $1"; pub const Q_003: &str = "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = $1"; pub const Q_004: &str = "SELECT uid, folder, subject, from_address, from_name, to_addresses, \ @@ -289,7 +300,7 @@ pub mod db_messages { ORDER BY date_epoch DESC LIMIT $1"; pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = $1"; - pub const Q_014: &str = "SELECT SUM(size) FROM messages"; + pub const Q_014: &str = "SELECT SUM(size)::bigint FROM messages"; pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS} FROM messages WHERE folder = $1 @@ -307,7 +318,7 @@ pub mod db_messages { FROM messages WHERE message_id = $1 OR in_reply_to = $1 - OR references_header LIKE $2 ESCAPE '\\' + OR references_header LIKE $2 ESCAPE '!' ORDER BY date_epoch ASC"; pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS} FROM messages @@ -318,19 +329,20 @@ pub mod db_messages { pub mod db_notification_preferences { pub const Q_001: &str = "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1"; - pub const Q_002: &str = "INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)"; + pub const Q_002: &str = + "INSERT INTO notification_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING"; pub const FORMAT_001: &str = "UPDATE notification_preferences SET {set_clause} WHERE id = ${idx}"; } pub mod db_pgp { pub const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at"; - pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=CURRENT_TIMESTAMP"; + pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=GREATEST(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=CURRENT_TIMESTAMP::text"; pub const Q_002: &str = "DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=$1"; - pub const Q_003: &str = "UPDATE pgp_keys SET trust=$2,updated_at=CURRENT_TIMESTAMP WHERE replace(upper(fingerprint),' ','')=$1"; + pub const Q_003: &str = "UPDATE pgp_keys SET trust=$2,updated_at=CURRENT_TIMESTAMP::text WHERE replace(upper(fingerprint),' ','')=$1"; pub const Q_004: &str = "SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1"; - pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=$1,hkp_enabled=$2,require_trusted_encryption=$3,updated_at=CURRENT_TIMESTAMP WHERE id=1"; + pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=$1,hkp_enabled=$2,require_trusted_encryption=$3,updated_at=CURRENT_TIMESTAMP::text WHERE id=1"; pub const FORMAT_001: &str = "SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint"; pub const FORMAT_002: &str = @@ -342,11 +354,13 @@ pub mod db_pgp { pub mod db_pool { pub const Q_001: &str = "SELECT 0"; - pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP"; + pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP::text"; pub const Q_003: &str = "SELECT 1"; pub const Q_004: &str = "SELECT 1"; pub const Q_005: &str = "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = current_schema() ORDER BY tablename"; pub const Q_006: &str = "SELECT 1"; + pub const Q_007: &str = + "SELECT COUNT(*) FROM pg_catalog.pg_tables WHERE schemaname = current_schema()"; } pub mod db_response_templates { @@ -355,7 +369,7 @@ pub mod db_response_templates { pub const Q_002: &str = "SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=$1"; pub const Q_003: &str = "INSERT INTO response_templates(name,body,format) VALUES ($1,$2,$3)"; - pub const Q_004: &str = "UPDATE response_templates SET name=$1,body=$2,format=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$4"; + pub const Q_004: &str = "UPDATE response_templates SET name=$1,body=$2,format=$3,updated_at=CURRENT_TIMESTAMP::text WHERE id=$4"; pub const Q_005: &str = "DELETE FROM response_templates WHERE id=$1"; } @@ -363,7 +377,7 @@ pub mod db_saved_searches { pub const Q_001: &str = "SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name "; pub const Q_002: &str = "INSERT INTO saved_searches(name,query_string) VALUES ($1,$2)"; - pub const Q_003: &str = "UPDATE saved_searches SET name=$1,query_string=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$3"; + pub const Q_003: &str = "UPDATE saved_searches SET name=$1,query_string=$2,updated_at=CURRENT_TIMESTAMP::text WHERE id=$3"; pub const Q_004: &str = "SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=$1"; pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=$1"; @@ -372,9 +386,9 @@ pub mod db_saved_searches { pub mod db_special_folders { pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = $1"; pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)"; - pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES($1, $2, CURRENT_TIMESTAMP)\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = CURRENT_TIMESTAMP"; + pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES($1, $2, CURRENT_TIMESTAMP::text)\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = CURRENT_TIMESTAMP::text"; pub const Q_004: &str = "DELETE FROM special_folders WHERE role = $1"; - pub const Q_005: &str = "UPDATE special_folders SET folder_name = $1, updated_at = CURRENT_TIMESTAMP WHERE folder_name = $2"; + pub const Q_005: &str = "UPDATE special_folders SET folder_name = $1, updated_at = CURRENT_TIMESTAMP::text WHERE folder_name = $2"; pub const Q_006: &str = "DELETE FROM special_folders WHERE folder_name = $1"; } @@ -389,9 +403,9 @@ pub mod db_tags { pub const Q_003: &str = "SELECT message_uid, message_folder FROM message_tags WHERE tag_id = $1"; pub const Q_004: &str = - "UPDATE tags SET name = $1, color = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3"; + "UPDATE tags SET name = $1, color = $2, updated_at = CURRENT_TIMESTAMP::text WHERE id = $3"; pub const Q_005: &str = "DELETE FROM tags WHERE id = $1"; - pub const Q_006: &str = "INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES ($1, $2, $3)"; + pub const Q_006: &str = "INSERT INTO message_tags (tag_id, message_uid, message_folder) VALUES ($1, $2, $3) ON CONFLICT (tag_id, message_uid, message_folder) DO NOTHING"; pub const Q_007: &str = "DELETE FROM message_tags WHERE tag_id = $1 AND message_uid = $2 AND message_folder = $3"; pub const Q_008: &str = "SELECT t.id, t.name, t.color @@ -409,7 +423,7 @@ pub mod db_tags { ORDER BY m.date_epoch DESC LIMIT $2 OFFSET $3"; pub const Q_010: &str = "SELECT COUNT(*) FROM message_tags WHERE tag_id = $1"; - pub const Q_011: &str = "INSERT OR IGNORE INTO folders (name) VALUES ($1)"; + pub const Q_011: &str = "INSERT INTO folders (name) VALUES ($1) ON CONFLICT (name) DO NOTHING"; pub const Q_012: &str = "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet) VALUES ($1, $2, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')"; pub const FORMAT_001: &str = "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color @@ -421,14 +435,15 @@ pub mod db_tags { pub mod db_trusted_senders { pub const Q_001: &str = "SELECT email FROM trusted_senders ORDER BY lower(email)"; - pub const Q_002: &str = "INSERT OR IGNORE INTO trusted_senders(email) VALUES($1)"; + pub const Q_002: &str = + "INSERT INTO trusted_senders(email) VALUES($1) ON CONFLICT (email) DO NOTHING"; pub const Q_003: &str = "DELETE FROM trusted_senders WHERE email = $1 "; } pub mod routes_contacts { - pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP"; - pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP"; - pub const Q_003: &str = "SELECT CURRENT_TIMESTAMP"; + pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP::text"; + pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP::text"; + pub const Q_003: &str = "SELECT CURRENT_TIMESTAMP::text"; } pub mod routes_diagnostics { @@ -442,10 +457,10 @@ pub mod routes_diagnostics { } pub mod routes_folder_mgmt { - pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN $2 ELSE flags || ',' || $2 END WHERE folder = $1 AND instr(flags, $2) = 0"; + pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN $2 ELSE flags || ',' || $2 END WHERE folder = $1 AND POSITION($2 IN flags) = 0"; pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = $1"; pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = $1"; - pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP WHERE name = $1"; + pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $1"; } pub mod routes_messages_mod { diff --git a/src/queries/sqlite/queries.rs b/src/queries/sqlite/queries.rs index 5d011cf..d3e4249 100644 --- a/src/queries/sqlite/queries.rs +++ b/src/queries/sqlite/queries.rs @@ -84,7 +84,7 @@ pub mod db_contacts { pub const Q_003: &str = r#" SELECT email, name FROM known_addresses - WHERE (email LIKE ?1 ESCAPE '\' OR name LIKE ?1 ESCAPE '\') + WHERE (email LIKE ?1 ESCAPE '!' OR name LIKE ?1 ESCAPE '!') AND email NOT IN (SELECT email FROM contacts) AND NOT EXISTS ( SELECT 1 FROM contacts c, json_each(c.emails_json) alt @@ -112,14 +112,14 @@ pub mod db_contacts { WHERE lower(email) = lower(?1) \ OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))"; pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\' + WHERE name LIKE ?1 ESCAPE '!' OR email LIKE ?1 ESCAPE '!' OR emails_json LIKE ?1 ESCAPE '!' ORDER BY name ASC LIMIT ?2 OFFSET ?3"; pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts ORDER BY name ASC LIMIT ?1 OFFSET ?2"; pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\' + WHERE name LIKE ?1 ESCAPE '!' OR email LIKE ?1 ESCAPE '!' OR emails_json LIKE ?1 ESCAPE '!' ORDER BY name ASC LIMIT ?2"; } @@ -200,11 +200,11 @@ pub mod db_folders { pub const Q_010: &str = "UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2"; pub const Q_011: &str = "SELECT EXISTS( SELECT 1 FROM folders - WHERE updated_at > datetime('now', ?1) + WHERE updated_at > datetime('now', printf('-%d seconds', ?1)) )"; pub const Q_012: &str = "SELECT EXISTS( SELECT 1 FROM folders - WHERE name = ?1 AND messages_updated_at > datetime('now', ?2) + WHERE name = ?1 AND messages_updated_at > datetime('now', printf('-%d seconds', ?2)) )"; pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1"; pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?1"; @@ -307,7 +307,7 @@ pub mod db_messages { FROM messages WHERE message_id = ?1 OR in_reply_to = ?1 - OR references_header LIKE ?2 ESCAPE '\\' + OR references_header LIKE ?2 ESCAPE '!' ORDER BY date_epoch ASC"; pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS} FROM messages @@ -347,6 +347,8 @@ pub mod db_pool { pub const Q_004: &str = "PRAGMA foreign_keys=ON;"; pub const Q_005: &str = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"; pub const Q_006: &str = "PRAGMA foreign_keys"; + pub const Q_007: &str = + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"; pub fn set_user_version(version: u32) -> String { format!("PRAGMA user_version = {version};") @@ -532,11 +534,11 @@ macro_rules! rustbox_query_db_contacts_002 { #[macro_export] macro_rules! rustbox_query_db_contacts_003 { () => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\' + WHERE name LIKE ?1 ESCAPE '!' OR email LIKE ?1 ESCAPE '!' OR emails_json LIKE ?1 ESCAPE '!' ORDER BY name ASC LIMIT ?2 OFFSET ?3") }; ($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\' + WHERE name LIKE ?1 ESCAPE '!' OR email LIKE ?1 ESCAPE '!' OR emails_json LIKE ?1 ESCAPE '!' ORDER BY name ASC LIMIT ?2 OFFSET ?3", $($args)+) }; } @@ -554,11 +556,11 @@ macro_rules! rustbox_query_db_contacts_004 { #[macro_export] macro_rules! rustbox_query_db_contacts_005 { () => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\' + WHERE name LIKE ?1 ESCAPE '!' OR email LIKE ?1 ESCAPE '!' OR emails_json LIKE ?1 ESCAPE '!' ORDER BY name ASC LIMIT ?2") }; ($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts - WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\' + WHERE name LIKE ?1 ESCAPE '!' OR email LIKE ?1 ESCAPE '!' OR emails_json LIKE ?1 ESCAPE '!' ORDER BY name ASC LIMIT ?2", $($args)+) }; } @@ -643,13 +645,13 @@ macro_rules! rustbox_query_db_messages_004 { FROM messages WHERE message_id = ?1 OR in_reply_to = ?1 - OR references_header LIKE ?2 ESCAPE '\\' + OR references_header LIKE ?2 ESCAPE '!' ORDER BY date_epoch ASC") }; ($($args:tt)+) => { format!("SELECT {MSG_SELECT_COLS} FROM messages WHERE message_id = ?1 OR in_reply_to = ?1 - OR references_header LIKE ?2 ESCAPE '\\' + OR references_header LIKE ?2 ESCAPE '!' ORDER BY date_epoch ASC", $($args)+) }; } diff --git a/src/realtime/sync.rs b/src/realtime/sync.rs index 5ce780c..de58ac0 100644 --- a/src/realtime/sync.rs +++ b/src/realtime/sync.rs @@ -318,20 +318,20 @@ async fn sync_condstore( let mut folder_changed = !changed.is_empty(); for (uid, mut flags) in changed { flags.sort(); - db::messages::update_message_flags(conn, &name, uid, &flags.join(","))?; + db::messages::update_message_flags(&transaction, &name, uid, &flags.join(","))?; if sync_keywords { - db::tags::import_imap_keywords(conn, uid, &name, &flags)?; + db::tags::import_imap_keywords(&transaction, uid, &name, &flags)?; } } if let Some(imap_uids) = imap_uids { - for (uid, _) in db::messages::get_all_uids_and_flags(conn, &name)? { + for (uid, _) in db::messages::get_all_uids_and_flags(&transaction, &name)? { if !imap_uids.contains(&uid) { - db::messages::delete_message(conn, &name, uid)?; + db::messages::delete_message(&transaction, &name, uid)?; folder_changed = true; } } } - let max_uid = db::messages::max_uid(conn, &name)?; + let max_uid = db::messages::max_uid(&transaction, &name)?; let new_messages = status.uid_next > max_uid.saturating_add(1); let modseq = if new_modseq > 0 { new_modseq @@ -339,14 +339,14 @@ async fn sync_condstore( status.highest_modseq }; db::folders::update_folder_sync_status( - conn, + &transaction, &name, status.uid_validity, status.exists, modseq, )?; if new_messages { - db::folders::invalidate_folder_freshness(conn, &name)?; + db::folders::invalidate_folder_freshness(&transaction, &name)?; folder_changed = true; } transaction.commit().map_err(|e| e.to_string())?; @@ -448,7 +448,7 @@ async fn sync_full( for (uid, cached_flags_csv) in &cached { match imap_map.get(uid) { None => { - db::messages::delete_message(conn, folder_name, *uid)?; + db::messages::delete_message(&transaction, folder_name, *uid)?; folder_changed = true; tracing::debug!( folder = %folder_name, @@ -467,7 +467,7 @@ async fn sync_full( if cached_normalized != imap_flags_csv { db::messages::update_message_flags( - conn, + &transaction, folder_name, *uid, &imap_flags_csv, @@ -475,14 +475,19 @@ async fn sync_full( folder_changed = true; } if sync_keywords { - db::tags::import_imap_keywords(conn, *uid, folder_name, imap_flags)?; + db::tags::import_imap_keywords( + &transaction, + *uid, + folder_name, + imap_flags, + )?; } } } } db::folders::update_folder_sync_status( - conn, + &transaction, folder_name, status.uid_validity, status.exists, diff --git a/src/routes/calendar.rs b/src/routes/calendar.rs index 9399d0e..72b72fd 100644 --- a/src/routes/calendar.rs +++ b/src/routes/calendar.rs @@ -174,7 +174,7 @@ pub async fn export_ics( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let mut body = String::from( - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.1.0//EN\r\nCALSCALE:GREGORIAN\r\n", + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.1.1//EN\r\nCALSCALE:GREGORIAN\r\n", ); for event in &events { append_ics_event(&mut body, event); diff --git a/src/routes/contacts.rs b/src/routes/contacts.rs index 9583b56..99e2aa0 100644 --- a/src/routes/contacts.rs +++ b/src/routes/contacts.rs @@ -184,11 +184,7 @@ pub async fn create_contact_handler( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let now: String = conn - .query_row( - crate::queries::sqlite::queries::routes_contacts::Q_001, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(routes_contacts::Q_001), [], |row| row.get(0)) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let id = body.id.unwrap_or_else(|| Uuid::new_v4().to_string()); let email = body.email.trim().to_string(); @@ -361,11 +357,7 @@ pub async fn merge_contacts_handler( target.last_contacted = source.last_contacted.clone(); } target.updated_at = tx - .query_row( - crate::queries::sqlite::queries::routes_contacts::Q_002, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(routes_contacts::Q_002), [], |row| row.get(0)) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; for group in db::contact_groups::list_contact_groups(&tx, &source.id) @@ -966,11 +958,7 @@ pub async fn import_contacts_handler( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let now: String = conn - .query_row( - crate::queries::sqlite::queries::routes_contacts::Q_003, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(routes_contacts::Q_003), [], |row| row.get(0)) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let parsed = parse_vcards(&vcf_content); diff --git a/src/routes/diagnostics.rs b/src/routes/diagnostics.rs index 24e36f9..b748b40 100644 --- a/src/routes/diagnostics.rs +++ b/src/routes/diagnostics.rs @@ -221,19 +221,13 @@ pub async fn sync_diagnostics( Option, Option, ) = conn - .query_row( - crate::queries::sqlite::queries::routes_diagnostics::Q_001, - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - ) + .query_row(crate::dbq!(routes_diagnostics::Q_001), [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) .map_err(|e| format!("Failed to read folder sync metadata: {e}"))?; let cached_messages: i64 = conn - .query_row( - crate::queries::sqlite::queries::routes_diagnostics::Q_002, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(routes_diagnostics::Q_002), [], |row| row.get(0)) .map_err(|e| format!("Failed to count cached messages: {e}"))?; Ok(SyncDiagnosticsResponse { @@ -273,11 +267,7 @@ pub async fn search_diagnostics( .filter(|message| UserIndex::is_excluded_folder(&message.folder)) .count(); let cached_bodies: usize = conn - .query_row( - crate::queries::sqlite::queries::routes_diagnostics::Q_003, - [], - |row| row.get(0), - ) + .query_row(crate::dbq!(routes_diagnostics::Q_003), [], |row| row.get(0)) .map_err(|e| format!("Failed to count cached bodies: {e}"))?; let indexed_documents = engine.open_user_index(&user_hash)?.document_count(); diff --git a/src/routes/drafts.rs b/src/routes/drafts.rs index 3b3b072..afdad27 100644 --- a/src/routes/drafts.rs +++ b/src/routes/drafts.rs @@ -316,7 +316,7 @@ pub(crate) async fn remove_remote_draft_snapshot( } fn load_snapshot_attachments( - conn: &rusqlite::Connection, + conn: &crate::db::pool::Connection, draft_id: &str, ) -> Result, String> { let records = db::drafts::get_draft_attachments(conn, draft_id)?; diff --git a/src/routes/folder_mgmt.rs b/src/routes/folder_mgmt.rs index 89f6373..8e6ccde 100644 --- a/src/routes/folder_mgmt.rs +++ b/src/routes/folder_mgmt.rs @@ -199,15 +199,12 @@ pub async fn mark_all_read( let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; conn.execute( - crate::queries::sqlite::queries::routes_folder_mgmt::Q_001, - rusqlite::params![&name, "\\Seen"], - ) - .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - conn.execute( - crate::queries::sqlite::queries::routes_folder_mgmt::Q_002, - [&name], + crate::dbq!(routes_folder_mgmt::Q_001), + crate::db::pool::params![&name, "\\Seen"], ) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + conn.execute(crate::dbq!(routes_folder_mgmt::Q_002), [&name]) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; Ok(Json(MessageResponse { status: "read" }).into_response()) } @@ -229,7 +226,7 @@ pub async fn empty_folder( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let uids: Vec = { let mut statement = conn - .prepare(crate::queries::sqlite::queries::routes_folder_mgmt::Q_003) + .prepare(crate::dbq!(routes_folder_mgmt::Q_003)) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let rows = statement .query_map([&name], |row| row.get(0)) @@ -238,11 +235,8 @@ pub async fn empty_folder( }; db::messages::delete_folder_messages(&conn, &name) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - conn.execute( - crate::queries::sqlite::queries::routes_folder_mgmt::Q_004, - [&name], - ) - .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + conn.execute(crate::dbq!(routes_folder_mgmt::Q_004), [&name]) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; if let Ok(index) = search_engine.open_user_index(&session.user_hash) { for uid in uids { diff --git a/src/routes/identities.rs b/src/routes/identities.rs index 2258d67..3a1ebfd 100644 --- a/src/routes/identities.rs +++ b/src/routes/identities.rs @@ -91,7 +91,7 @@ pub async fn delete_identity_handler( } fn normalize_and_validate_pgp_key( - conn: &rusqlite::Connection, + conn: &crate::db::pool::Connection, value: &mut Option, ) -> Result<(), AppError> { let Some(raw) = value.as_ref() else { diff --git a/src/routes/messages/mod.rs b/src/routes/messages/mod.rs index ff33d50..d019118 100644 --- a/src/routes/messages/mod.rs +++ b/src/routes/messages/mod.rs @@ -322,7 +322,7 @@ pub async fn list_messages( } fn upsert_and_index_headers( - conn: &rusqlite::Transaction<'_>, + conn: &crate::db::pool::Transaction, folder: &str, headers: &[crate::imap::types::ImapMessageHeader], search_engine: &Arc, @@ -853,8 +853,8 @@ pub async fn update_flags( let current_flags_csv: String = conn .query_row( - crate::queries::sqlite::queries::routes_messages_mod::Q_001, - rusqlite::params![&folder, uid], + crate::dbq!(routes_messages_mod::Q_001), + crate::db::pool::params![&folder, uid], |row| row.get(0), ) .unwrap_or_default(); diff --git a/src/routes/pgp.rs b/src/routes/pgp.rs index 9dc7214..11f45d1 100644 --- a/src/routes/pgp.rs +++ b/src/routes/pgp.rs @@ -481,8 +481,8 @@ pub async fn delete_key( database .run(move |conn| { conn.execute( - crate::queries::sqlite::queries::routes_pgp::Q_001, - rusqlite::params![delete_fp], + crate::dbq!(routes_pgp::Q_001), + crate::db::pool::params![delete_fp], ) .map_err(|e| format!("Failed to detach OpenPGP key from identities: {e}"))?; db::pgp::delete_key(conn, &normalized) diff --git a/src/routes/send.rs b/src/routes/send.rs index 5317a54..59055b0 100644 --- a/src/routes/send.rs +++ b/src/routes/send.rs @@ -411,7 +411,7 @@ async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) { } fn resolve_recipient_public_keys( - conn: &rusqlite::Connection, + conn: &crate::db::pool::Connection, to: &[String], cc: &[String], ) -> Result, AppError> { @@ -437,7 +437,7 @@ fn resolve_recipient_public_keys( } fn resolve_sender_public_key( - conn: &rusqlite::Connection, + conn: &crate::db::pool::Connection, identity: Option<&crate::db::identities::Identity>, sender_email: &str, ) -> Result<(String, String), AppError> { @@ -486,7 +486,7 @@ fn resolve_pgp_mode( #[allow(clippy::too_many_arguments)] fn build_pgp_send_config( - conn: &rusqlite::Connection, + conn: &crate::db::pool::Connection, data_dir: &str, user_hash: &str, mode: PgpMode, diff --git a/src/routes/special_folders.rs b/src/routes/special_folders.rs index 29d1d3a..ecc7dba 100644 --- a/src/routes/special_folders.rs +++ b/src/routes/special_folders.rs @@ -1,8 +1,8 @@ use std::sync::Arc; +use crate::db::pool::Connection; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; -use rusqlite::Connection; use crate::auth::session::SessionState; use crate::config::AppConfig; @@ -23,7 +23,7 @@ fn configured_folder_if_present( }; let exists: bool = conn .query_row( - crate::queries::sqlite::queries::routes_special_folders::Q_001, + crate::dbq!(routes_special_folders::Q_001), [folder], |row| row.get(0), ) diff --git a/src/routes/tags.rs b/src/routes/tags.rs index d43655b..54f0162 100644 --- a/src/routes/tags.rs +++ b/src/routes/tags.rs @@ -107,7 +107,7 @@ fn imap_credentials(session: &SessionState) -> ImapCredentials { } } -fn keyword_sync_enabled(conn: &rusqlite::Connection) -> bool { +fn keyword_sync_enabled(conn: &crate::db::pool::Connection) -> bool { db::mail_preferences::get_preferences(conn) .map(|prefs| prefs.sync_imap_keywords) .unwrap_or(false) diff --git a/src/routes/unsubscribe.rs b/src/routes/unsubscribe.rs index e705541..7b79bfe 100644 --- a/src/routes/unsubscribe.rs +++ b/src/routes/unsubscribe.rs @@ -228,7 +228,7 @@ async fn post_one_click_to_address( format!("{host}:{port}") }; let request = format!( - "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.1.0\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", + "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.1.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", ONE_CLICK_BODY.len() ); diff --git a/src/session_store/database.rs b/src/session_store/database.rs index 99c42f2..06e3f87 100644 --- a/src/session_store/database.rs +++ b/src/session_store/database.rs @@ -6,6 +6,7 @@ use sqlx::{FromRow, MySqlPool, PgPool}; use super::{PersistedSession, SessionPersistence}; use crate::config::AppConfig; +use crate::db::pool::{DatabaseBackend, global_schema_for_backend}; use crate::queries; #[derive(Clone)] @@ -49,21 +50,19 @@ impl TryFrom for PersistedSession { password_ciphertext: row.password_ciphertext, user_hash: row.user_hash, imap_host: row.imap_host, - imap_port: u16::try_from(row.imap_port) - .map_err(|_| "invalid imap_port in session database")?, + imap_port: u16::try_from(row.imap_port).map_err(|_| "invalid imap_port in database")?, imap_tls: row.imap_tls != 0, imap_tls_verify: row.imap_tls_verify != 0, smtp_host: row.smtp_host, - smtp_port: u16::try_from(row.smtp_port) - .map_err(|_| "invalid smtp_port in session database")?, + smtp_port: u16::try_from(row.smtp_port).map_err(|_| "invalid smtp_port in database")?, smtp_tls: row.smtp_tls != 0, smtp_tls_verify: row.smtp_tls_verify != 0, timeout_secs: u64::try_from(row.timeout_secs) - .map_err(|_| "invalid timeout_secs in session database")?, + .map_err(|_| "invalid timeout_secs in database")?, created_at_unix: u64::try_from(row.created_at_unix) - .map_err(|_| "invalid created_at_unix in session database")?, + .map_err(|_| "invalid created_at_unix in database")?, last_accessed_unix: u64::try_from(row.last_accessed_unix) - .map_err(|_| "invalid last_accessed_unix in session database")?, + .map_err(|_| "invalid last_accessed_unix in database")?, user_agent: row.user_agent, }) } @@ -133,8 +132,8 @@ where F: FnOnce(Connection) -> Result + Send + 'static, { tokio::task::spawn_blocking(move || { - let conn = Connection::open(path) - .map_err(|e| format!("failed to open SQLite session database: {e}"))?; + let conn = + Connection::open(path).map_err(|e| format!("failed to open SQLite database: {e}"))?; conn.busy_timeout(std::time::Duration::from_secs(5)) .map_err(|e| format!("failed to configure SQLite busy timeout: {e}"))?; operation(conn) @@ -154,9 +153,11 @@ impl DatabaseSessionStore { "sqlite" => { let path = sqlite_path(config)?; let migration_path = path.clone(); - sqlite_task(migration_path, |conn| { - conn.execute_batch(include_str!("../../migrations/sqlite/V047__sessions.sql")) - .map_err(|e| format!("SQLite session migration failed: {e}"))?; + let schema = global_schema_for_backend(DatabaseBackend::Sqlite)?; + sqlite_task(migration_path, move |conn| { + conn.execute_batch(&schema).map_err(|e| { + format!("SQLite database schema initialization failed: {e}") + })?; Ok(()) }) .await?; @@ -169,11 +170,12 @@ impl DatabaseSessionStore { .ok_or("DATABASE_URL required for mysql")?; let pool = MySqlPool::connect(url) .await - .map_err(|e| format!("failed to connect MySQL session database: {e}"))?; - sqlx::raw_sql(include_str!("../../migrations/mysql/V047__sessions.sql")) + .map_err(|e| format!("failed to connect MySQL database: {e}"))?; + let schema = global_schema_for_backend(DatabaseBackend::Mysql)?; + sqlx::raw_sql(&schema) .execute(&pool) .await - .map_err(|e| format!("MySQL session migration failed: {e}"))?; + .map_err(|e| format!("MySQL database schema initialization failed: {e}"))?; Pool::Mysql(pool) } "pgsql" | "postgres" | "postgresql" => { @@ -183,11 +185,11 @@ impl DatabaseSessionStore { .ok_or("DATABASE_URL required for pgsql")?; let pool = PgPool::connect(url) .await - .map_err(|e| format!("failed to connect PostgreSQL session database: {e}"))?; - sqlx::raw_sql(include_str!("../../migrations/pgsql/V047__sessions.sql")) - .execute(&pool) - .await - .map_err(|e| format!("PostgreSQL session migration failed: {e}"))?; + .map_err(|e| format!("failed to connect PostgreSQL database: {e}"))?; + let schema = global_schema_for_backend(DatabaseBackend::Pgsql)?; + sqlx::raw_sql(&schema).execute(&pool).await.map_err(|e| { + format!("PostgreSQL database schema initialization failed: {e}") + })?; Pool::Pgsql(pool) } other => return Err(format!("unsupported database backend: {other}")), diff --git a/src/sieve/error.rs b/src/sieve/error.rs index 69bf029..13febe6 100644 --- a/src/sieve/error.rs +++ b/src/sieve/error.rs @@ -29,7 +29,7 @@ impl fmt::Display for SieveError { write!(f, "ManageSieve server rejected the command: {msg}") } Self::Timeout => write!(f, "ManageSieve operation timed out"), - Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.1.0 size limit"), + Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.1.1 size limit"), } } } diff --git a/tests/roundcube_skin_contract.py b/tests/roundcube_skin_contract.py deleted file mode 100644 index abcbfad..0000000 --- a/tests/roundcube_skin_contract.py +++ /dev/null @@ -1,157 +0,0 @@ -from pathlib import Path -import json -import sqlite3 -from html.parser import HTMLParser -import tinycss2 - -ROOT = Path(__file__).resolve().parents[1] - - -class IdCollector(HTMLParser): - def __init__(self): - super().__init__() - self.ids = [] - - def handle_starttag(self, _tag, attrs): - attrs = dict(attrs) - if "id" in attrs: - self.ids.append(attrs["id"]) - - -def js_bundle_source(): - js_dir = ROOT / "src/web/js" - names = [line.strip() for line in (js_dir / "manifest.txt").read_text().splitlines() if line.strip() and not line.lstrip().startswith("#")] - assert len(names) >= 10 - assert len(names) == len(set(names)) - return names, "\n".join((js_dir / name).read_text() for name in names) - - -def test_schema_and_response_templates(): - conn = sqlite3.connect(":memory:") - migrations = sorted(ROOT.joinpath("migrations").glob("V*.sql")) - for migration in migrations: - conn.executescript(migration.read_text()) - assert int(migrations[-1].name[1:4]) == 46 - display_columns = {row[1] for row in conn.execute("pragma table_info(display_preferences)")} - response_columns = {row[1] for row in conn.execute("pragma table_info(response_templates)")} - assert "interface_skin" in display_columns - assert "preset_scale_unlocked" in display_columns - assert "format" in response_columns - conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("Text", "Hello", "text")) - conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("HTML", "Hello", "html")) - assert conn.execute("select count(*) from response_templates").fetchone()[0] == 2 - - -def test_html_contract(): - source = ROOT.joinpath("src/web/index.html").read_text() - parser = IdCollector() - parser.feed(source) - duplicates = {value for value in parser.ids if parser.ids.count(value) > 1} - assert not duplicates - assert "folder-drawer-header" in source - assert "#icon-arrow-left" in source - assert "pref-interface-skin" in source - assert "compose-response-template" in source - assert 'data-default-theme="__RUSTBOX_DEFAULT_THEME__"' in source - assert 'id="folder-quota"' in source - assert 'id="folder-quota-bar"' in source - assert 'id="reader-to"' in source and 'aria-expanded="false"' in source - for control_id in ["message-details-menu", "message-transfer-menu", "reader-move-action", "reader-copy-action", "compose-mode"]: - assert f'id="{control_id}"' in source - assert "data-appearance-preset" not in source - for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord", "github", "gitlab", "discord", "slack", "reddit", "notion"]: - assert f'value="{value}"' in source - - -def test_roundcube_palette_contract(): - classic_elastic = (ROOT / "src/web/css/55-roundcube-skins.css").read_text().lower() - extra_styles = (ROOT / "src/web/css/56-interface-styles.css").read_text().lower() - for css in [classic_elastic, extra_styles]: - errors = [item for item in tinycss2.parse_stylesheet(css, skip_comments=False, skip_whitespace=False) if item.type == "error"] - assert not errors - for token in ["#f6f6f6", "#f2f2f2", "#cc3333", "#929292", "#37beff", "#2f3a3f", "#21292c", "#c5d1d3", "#4d6066", "#374549"]: - assert token in classic_elastic - for selector in [ - 'roundcube-classic"][data-theme="light', - 'roundcube-classic"][data-theme="dark', - 'roundcube-elastic"][data-theme="light', - 'roundcube-elastic"][data-theme="dark', - ]: - assert selector in classic_elastic - for selector in ["roundcube-larry", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast"]: - assert f'data-interface-skin="{selector}"' in extra_styles - manifest = (ROOT / "src/web/css/manifest.txt").read_text() - assert "55-roundcube-skins.css" in manifest - assert "56-interface-styles.css" in manifest - - -def test_modular_js_contract(): - assert not (ROOT / "src/web/app.js").exists() - names, app = js_bundle_source() - assert names[0] == "00-bootstrap.js" - assert names[-1] == "95-bindings-boot.js" - assert "interfaceStyleProfiles" in app - assert '"roundcube-classic"' in app - assert '"roundcube-larry"' in app - assert '"roundcube-elastic"' in app - assert "loadResponseTemplates" in app - assert "insertSavedResponse" in app - assert "loadQuota();" in app - assert "toggleReaderAddressExpansion" in app - assert "translateScopedUiText" in app - assert "presetScaleUnlocked" in app - assert "positionToolbarMenu" in app - assert "progressToast" not in app - assert "attachmentPreviewLimitBytes" in app - assert 'label: "Preview"' in app - assert "This attachment is too large for preview. Download it instead?" in app - assert "appearancePresets" not in app - assert "applyAppearancePreset" not in app - assert "src/web/js/manifest.txt" in (ROOT / "README.md").read_text() - build = (ROOT / "build.rs").read_text() - assert 'root.join("src/web/js")' in build - assert 'out.join("rustbox-ui.js")' in build - assert 'include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.js"))' in (ROOT / "src/ui.rs").read_text() - - -def test_backend_skin_contract_and_version(): - db_source = (ROOT / "src/db/display_preferences.rs").read_text() - for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord", "github", "gitlab", "discord", "slack", "reddit", "notion"]: - assert f'"{value}"' in db_source - cargo = (ROOT / "Cargo.toml").read_text() - assert 'version = "0.1.0"' in cargo - config = (ROOT / "src/config.rs").read_text() - assert "pub default_theme: String" in config - assert '"system" | "dark" | "light"' in config - ui = (ROOT / "src/ui.rs").read_text() - assert 'replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)' in ui - assert "V044__interface_skin.sql" in (ROOT / "src/db/pool.rs").read_text() - assert "V045__response_template_format.sql" in (ROOT / "src/db/pool.rs").read_text() - assert "V046__preset_scale_unlock.sql" in (ROOT / "src/db/pool.rs").read_text() - for path in ROOT.glob("src/web/i18n/*.json"): - json.loads(path.read_text()) - - -def test_mobile_003_contract(): - responsive = (ROOT / "src/web/css/60-responsive.css").read_text() - assert '.sieve-builder .checkbox-row input[type="checkbox"]' in responsive - assert ".dialog>.dialog-card" in responsive - assert "grid-template-rows: auto minmax(0, 1fr)" in responsive - assert ".calendar-toolbar" in responsive - assert ".message-meta.addresses-expanded" in responsive - pl = json.loads((ROOT / "src/web/i18n/pl.json").read_text()) - assert len(pl.get("_text", {})) >= 450 - for key in ["Rule enabled", "Stop after this rule", "Calendar", "Message headers", "Message source", "Forward as attachment", "Show headers", "Show source", "Write a message", "Preview", "Download", "Preview unavailable"]: - assert key in pl["_text"] - for key in ["reply", "reply_all", "forward", "message_details", "move_copy", "move_to", "copy_to"]: - assert key in pl - - -if __name__ == "__main__": - test_schema_and_response_templates() - test_html_contract() - test_roundcube_palette_contract() - test_modular_js_contract() - test_backend_skin_contract_and_version() - test_mobile_003_contract() - print("roundcube-skin-contract: OK") diff --git a/tests/test_fresh_sqlite_migrations.py b/tests/test_fresh_sqlite_migrations.py deleted file mode 100644 index 24c1c27..0000000 --- a/tests/test_fresh_sqlite_migrations.py +++ /dev/null @@ -1,32 +0,0 @@ -from pathlib import Path -import sqlite3 - -ROOT = Path(__file__).resolve().parents[1] - - -def test_all_user_sqlite_migrations_apply_on_fresh_database(tmp_path): - db_path = tmp_path / "user.sqlite" - conn = sqlite3.connect(db_path) - migrations = sorted((ROOT / "migrations/sqlite").glob("V*.sql")) - user_migrations = [m for m in migrations if m.name != "V047__sessions.sql"] - assert len(user_migrations) == 46 - for migration in user_migrations: - conn.executescript(migration.read_text()) - version = int(migration.name[1:4]) - conn.execute(f"PRAGMA user_version = {version}") - assert conn.execute("PRAGMA user_version").fetchone()[0] == 46 - tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} - assert {"user_meta", "folders", "messages", "drafts", "contacts"} <= tables - assert "sessions" not in tables - conn.close() - - -def test_session_sqlite_migration_is_separate_and_valid(tmp_path): - db_path = tmp_path / "rustbox.sqlite" - conn = sqlite3.connect(db_path) - conn.executescript((ROOT / "migrations/sqlite/V047__sessions.sql").read_text()) - tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} - indexes = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='index'")} - assert "sessions" in tables - assert {"idx_sessions_browser", "idx_sessions_user", "idx_sessions_expiry"} <= indexes - conn.close() diff --git a/tests/test_message_projection_010.py b/tests/test_message_projection_010.py deleted file mode 100644 index 68ffef5..0000000 --- a/tests/test_message_projection_010.py +++ /dev/null @@ -1,74 +0,0 @@ -from pathlib import Path -import re - -ROOT = Path(__file__).resolve().parents[1] -EXPECTED = ( - "uid, folder, message_id, in_reply_to, references_header, subject, " - "from_address, from_name, to_addresses, cc_addresses, date, flags, " - "size, has_attachments, snippet, reaction" -) - - -def read(rel: str) -> str: - return (ROOT / rel).read_text() - - -def test_cached_message_projection_is_stable_for_all_sql_dialects(): - for dialect in ("sqlite", "mysql", "pgsql"): - text = read(f"src/queries/{dialect}/queries.rs") - m = re.search(r'pub const MSG_SELECT_COLS: &str = "([^"]+)";', text) - assert m, dialect - assert m.group(1) == EXPECTED, dialect - - -def test_cached_message_row_mapping_matches_projection(): - text = read("src/db/messages.rs") - assert "let has_attachments_int: i32 = row.get(13)?;" in text - assert "flags: row.get(11)?" in text - assert "size: row.get(12)?" in text - assert "snippet: row.get(14)?" in text - assert "reaction: row.get(15)?" in text - - -def test_cached_message_projection_does_not_include_body_cache_columns(): - sqlite = read("src/queries/sqlite/queries.rs") - m = re.search(r'pub const MSG_SELECT_COLS: &str = "([^"]+)";', sqlite) - projection = m.group(1) - for unexpected in ("date_epoch", "body_cached", "body_html", "body_text", "attachments_json", "raw_headers", "email_theme"): - assert unexpected not in projection - - -def test_cached_message_projection_executes_on_fresh_sqlite_schema(): - import sqlite3 - - conn = sqlite3.connect(":memory:") - migration_dir = ROOT / "migrations/sqlite" - for migration in sorted(migration_dir.glob("V*.sql")): - if migration.name.startswith("V047__sessions"): - continue - conn.executescript(migration.read_text()) - - conn.execute("INSERT OR IGNORE INTO folders(name) VALUES ('INBOX')") - conn.execute( - """ - INSERT INTO messages - (uid, folder, message_id, in_reply_to, references_header, subject, - from_address, from_name, to_addresses, cc_addresses, date, flags, - size, has_attachments, snippet, date_epoch, reaction) - VALUES (1, 'INBOX', 'm1', NULL, NULL, 'Subject', - 'a@example.com', 'Alice', '[]', '[]', '2026-10-30', '\\Seen', - 123, 1, 'snippet', 1761840000, 'ok') - """ - ) - - row = conn.execute( - f"SELECT {EXPECTED} FROM messages WHERE folder = ? AND uid = ?", - ("INBOX", 1), - ).fetchone() - - assert len(row) == 16 - assert row[11] == "\\Seen" - assert row[12] == 123 - assert row[13] == 1 - assert row[14] == "snippet" - assert row[15] == "ok" diff --git a/tests/test_query_layout_010.py b/tests/test_query_layout_010.py deleted file mode 100644 index 52e485d..0000000 --- a/tests/test_query_layout_010.py +++ /dev/null @@ -1,91 +0,0 @@ -from pathlib import Path -import re - -ROOT = Path(__file__).resolve().parents[1] -SRC = ROOT / "src" -DIALECTS = ("sqlite", "mysql", "pgsql") - -SQL_LITERAL = re.compile( - r'(?:r#{0,8})?"\s*(?:SELECT\s+(?:\*|[A-Za-z_(])|INSERT\s+INTO|INSERT\s+OR\s+(?:IGNORE|REPLACE)\s+INTO|UPDATE\s+[A-Za-z_]|DELETE\s+FROM|PRAGMA\s+[A-Za-z_]|WITH\s+[A-Za-z_]|CREATE\s+(?:TABLE|INDEX)|ALTER\s+TABLE|DROP\s+(?:TABLE|INDEX))', - re.IGNORECASE | re.MULTILINE, -) -CONST_NAME = re.compile(r"pub const\s+([A-Z][A-Z0-9_]*)\s*:") -MODULE = re.compile(r"^pub mod\s+([a-zA-Z0-9_]+)\s*\{", re.MULTILINE) - - -def read(path: str) -> str: - return (ROOT / path).read_text() - - -def test_central_query_tree_exists(): - for dialect in DIALECTS: - assert (ROOT / f"src/queries/{dialect}/queries.rs").is_file() - assert (ROOT / f"src/queries/{dialect}/mod.rs").is_file() - assert not (ROOT / "src/session_store/queries").exists() - - -def test_session_store_uses_central_query_tree(): - db = read("src/session_store/database.rs") - for dialect in DIALECTS: - assert f"queries::{dialect}::queries::sessions::" in db - - -def test_no_executable_sql_literals_outside_query_tree(): - offenders = [] - for path in SRC.rglob("*.rs"): - if "queries" in path.parts: - continue - text = path.read_text() - if SQL_LITERAL.search(text): - offenders.append(str(path.relative_to(ROOT))) - assert offenders == [] - - -def test_dialect_catalogs_have_same_modules_and_query_constants(): - catalogs = {d: read(f"src/queries/{d}/queries.rs") for d in DIALECTS} - base_modules = set(MODULE.findall(catalogs["sqlite"])) - assert "sessions" in base_modules - for dialect in DIALECTS[1:]: - assert set(MODULE.findall(catalogs[dialect])) == base_modules - - # Every dialect must expose the same named query constants/templates. - base_consts = set(CONST_NAME.findall(catalogs["sqlite"])) - assert len(base_consts) >= 10 - for dialect in DIALECTS[1:]: - assert set(CONST_NAME.findall(catalogs[dialect])) == base_consts - - -def test_backend_specific_session_placeholders_are_preserved(): - sqlite = read("src/queries/sqlite/queries.rs") - mysql = read("src/queries/mysql/queries.rs") - pgsql = read("src/queries/pgsql/queries.rs") - assert 'BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = ?"' in sqlite - assert 'BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = ?"' in mysql - assert 'BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = $1"' in pgsql - - -def test_dynamic_query_macros_do_not_capture_caller_locals(): - sqlite = read("src/queries/sqlite/queries.rs") - # macro_rules identifiers are hygienic: caller-local values must arrive as metavariables. - forbidden = [ - 'WHERE id = ?{idx}', - 'ORDER BY {order_by}', - '{where_clause}', - 'SET {set_clause}', - 'user_version = {current}', - 'user_version = {version}', - ] - macro_section = sqlite[sqlite.index("#[macro_export]"):] - for fragment in forbidden: - assert fragment not in macro_section - - calendar = read("src/db/calendar.rs") - messages = read("src/db/messages.rs") - notifications = read("src/db/notification_preferences.rs") - pool = read("src/db/pool.rs") - assert 'rustbox_query_db_calendar_004!(sets.join(", "), idx)' in calendar - assert 'rustbox_query_db_messages_002!(&order_by)' in messages - assert 'rustbox_query_db_messages_005!(&where_clause, param_values.len() + 1)' in messages - assert 'rustbox_query_db_notification_preferences_001!(&set_clause, idx)' in notifications - assert re.search(r'db_pool::set_user_version\(\s*version,?\s*\)', pool) - assert 'refinery_schema_history' not in pool diff --git a/tests/test_release_005_contract.py b/tests/test_release_005_contract.py deleted file mode 100644 index 7d8da29..0000000 --- a/tests/test_release_005_contract.py +++ /dev/null @@ -1,52 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - -def read(path): - return (ROOT / path).read_text() - -def test_release_version(): - assert 'version = "0.1.0"' in read("Cargo.toml") - assert "# RustBox 0.1.0" in read("README.md") - -def test_realtime_contract(): - ws = read("src/realtime/ws.rs") - js = read("src/web/js/80-settings-realtime.js") - assert '"type": "heartbeat"' in ws - assert "Heartbeat timeout" in js - assert "2 ** attempt" in js - assert "resyncRealtimeState" in js - assert "realtimeSeenEvents" in js - assert "realtimePollTimer" in js - assert "realtimeRefreshEvents" in js - -def test_upload_progress_is_not_toast_driven(): - compose = read("src/web/js/50-compose.js") - block = compose[compose.index("async function uploadComposeFiles"):compose.index("function previewMarkdown")] - assert "progressToast(" not in block - assert "setComposeUploadProgress" in block - assert "AbortController" in block - -def test_fullscreen_reader_contract(): - html = read("src/web/index.html") - reader = read("src/web/js/40-messages-reader.js") - menu = read("src/web/js/60-mail-actions-search.js") - css = read("src/web/css/30-mail.css") - assert 'id="reader-fullscreen"' in html - assert "Open full screen" in menu - assert "message-fullscreen-action" in reader - assert "reader-fullscreen" in reader - assert "history.pushState" in reader - assert "reader-pane.reader-fullscreen" in css - assert ".reader-pane.reader-fullscreen .message-body" in css - assert "max-width: none" in css - -def test_mobile_reader_contract(): - css = read("src/web/css/30-mail.css") - responsive = read("src/web/css/60-responsive.css") - bindings = read("src/web/js/95-bindings-boot.js") - assert ".toolbar-menu-popover button" in css - assert ".attachment-item .attachment-button" in css - assert "min-height: 34px" in responsive - assert 'bottom: "8px"' not in bindings - assert 'rect.bottom + 5' in bindings diff --git a/tests/test_release_006_contract.py b/tests/test_release_006_contract.py deleted file mode 100644 index 230fd3b..0000000 --- a/tests/test_release_006_contract.py +++ /dev/null @@ -1,71 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - -def read(path): - return (ROOT / path).read_text() - -def test_release_version_is_at_least_006(): - import re - cargo = read("Cargo.toml") - match = re.search(r'^version = "(\d+)\.(\d+)\.(\d+)"$', cargo, re.M) - assert match - version = tuple(map(int, match.groups())) - assert version >= (0, 0, 6) - -def test_realtime_contract(): - ws = read("src/realtime/ws.rs") - js = read("src/web/js/80-settings-realtime.js") - assert '"type": "heartbeat"' in ws - assert "Heartbeat timeout" in js - assert "2 ** attempt" in js - assert "resyncRealtimeState" in js - assert "realtimeSeenEvents" in js - assert "realtimePollTimer" in js - assert "realtimeRefreshEvents" in js - -def test_upload_progress_is_not_toast_driven(): - compose = read("src/web/js/50-compose.js") - block = compose[compose.index("async function uploadComposeFiles"):compose.index("function previewMarkdown")] - assert "progressToast(" not in block - assert "setComposeUploadProgress" in block - assert "AbortController" in block - -def test_fullscreen_reader_contract(): - html = read("src/web/index.html") - reader = read("src/web/js/40-messages-reader.js") - menu = read("src/web/js/60-mail-actions-search.js") - css = read("src/web/css/30-mail.css") - assert 'id="reader-fullscreen"' in html - assert "Open full screen" in menu - assert "message-fullscreen-action" in reader - assert "reader-fullscreen" in reader - assert "history.pushState" in reader - assert "reader-pane.reader-fullscreen" in css - assert ".reader-pane.reader-fullscreen .message-body" in css - assert "max-width: none" in css - -def test_mobile_reader_contract(): - css = read("src/web/css/30-mail.css") - responsive = read("src/web/css/60-responsive.css") - bindings = read("src/web/js/95-bindings-boot.js") - assert ".toolbar-menu-popover button" in css - assert ".attachment-item .attachment-button" in css - assert "min-height: 34px" in responsive - assert 'bottom: "8px"' not in bindings - assert 'rect.bottom + 5' in bindings - -def test_message_links_can_open_new_tabs_from_sandbox(): - reader = read("src/web/js/40-messages-reader.js") - assert 'allow-popups allow-popups-to-escape-sandbox' in reader - - -def test_extended_color_presets_contract(): - html = read("src/web/index.html") - css = read("src/web/css/00-tokens.css") - backend = read("src/db/display_preferences.rs") - presets = ["emerald", "lime", "sky", "navy", "plum", "magenta", "copper", "sand"] - for preset in presets: - assert f'