Skip to content

Releases: max-ramas/rms-mail-public

Release v3.1.10: Audit remediation

Choose a tag to compare

@max-ramas max-ramas released this 16 Oct 17:11

Audit remediation (v3.1.9 findings).

Security

  • IMAP STARTTLS/SSL fail-closed: no silent DialInsecure fallback after declared STARTTLS/SSL; unit matrix for dial kinds.
  • MCP header-only auth: accept Authorization: Bearer / X-API-Key only; SSE endpoint URL uses opaque sessionId (no API key in query); last_used_at touched on successful auth.
  • Rate limits: Redis limiter degrades to in-memory on Redis errors; MCP /mcp/sse + /mcp/messages 30/min/IP; global API 60/min/IP (health skipped); /metrics behind AdminOnly.
  • SSE logout: cookie JWT path uses ValidateTokenNotRevoked (blacklist); JWT ?token= rejected everywhere including /mcp/*.

Sync integrity

  • UIDVALIDITY change: clear queue → purge local emails (DB + body files) → reset last_sync_uid → store new validity → full resync; regression test.

Frontend

  • Draft autosave invalidates only ["email", id] (+ folder counts), not emails-infinite.
  • refreshMailInbox uses refetch only (no invalidate+refetch storm).
  • Periodic poll pauses while SSE readyState/connected is open.
  • Trash detection via folders.special_use === "trash" with name fallbacks; Radix dropdown; theme tokens; i18n desktop notifications; light paper for HTML body in dark chrome.

Backend / tech debt

  • Migration 026_folders_special_use (+ mono); LIST with ReturnSpecialUse.
  • MCP store parity tests (SQLite always; Postgres integration + DATABASE_URL).
  • ToggleMCPKey moved to integrations.go; ports.JobStore breaks sync/job_worker → api import.
  • Webhook/MCP notes in reverse-proxy.md (Unified webhooks = Redis).
  • Mono/MonoPro login: after IMAP success, decrypt failure no longer calls CreateAccount (unique email 500); re-encrypt via UpdateAccount when the row exists.

v3.1.9

Choose a tag to compare

@max-ramas max-ramas released this 16 Oct 07:03

Changelog

[3.1.9] — 2026-10-15

Added

  • Remember Me: Added a "Remember me" option to the login screen with a 30-day session extension and persistent authentication cookies.

Fixed

  • Unread Counters: Fixed background counter drift where snoozed emails were counted as unread. RefreshUnreadCounts now excludes snoozed emails (snooze_until <= NOW()), matching the email list view.
  • Email List Stability (SSE): Eliminated email list flickering during background sync and SSE updates (such as folder_updated). Replaced destructive query resets with seamless cache page slicing and background invalidation (keepPreviousData).
  • Gmail Folder Hierarchy & Sorting: Fixed chaotic alphabetical folder sorting in the sidebar. Standard folders (Inbox, Sent, Drafts, Important, Starred, All Mail, Trash, Spam) across localized folder names and [Gmail]/ prefixes are now properly structured and prioritized before custom labels.
  • Security: Upgraded golang.org/x/text from v0.39.0 to v0.41.0 to resolve GO-2026-6629 vulnerability.

v3.1.8

Choose a tag to compare

@max-ramas max-ramas released this 27 Jul 15:39

Changelog

[3.1.8] — 2026-07-27

Fixed

  • Attachment Uploads (CAS): Fixed an issue where the frontend sent attachment UUIDs instead of CAS hashes during email composition (useMailCompose), causing attachments to be silently dropped by the backend's GetAttachmentByHash.
  • Security: Upgraded golang.org/x/text from v0.38.0 to v0.39.0 to fix the GO-2026-5970 infinite loop vulnerability.

v3.1.7

Choose a tag to compare

@max-ramas max-ramas released this 23 Jul 11:23

Changelog

[3.1.7] — 2026-07-23

Security / Deploy (post-audit hardening)

  • CI: fork PRs run on ubuntu-latest; same-repo pushes/PRs stay on self-hosted.
  • Git: stop tracking backend binaries / SQLite WAL-SHM (.gitignore + index cleanup).
  • MonoPro: ADMIN_EMAIL required at startup; removed first-registered-user admin fallback.
  • Setup cookie: HandleSetup uses rms_token via SetTokenCookie (was auth_token).
  • Rate limit: honor X-Forwarded-For / X-Real-IP only when RemoteAddr is in TRUSTED_PROXIES.
  • MCP auth: validate via key_hash (SHA-256 + constant-time); encrypted key still stored for UI view.
  • Camo: CAMO_HMAC_KEY required in production for U/MP; Mono may omit it and derive from ENCRYPTION_KEYS.
  • Compose U/MP: backend expose only (proxy via frontend :3000); keep user: "0:0".
  • IDLE INBOX: force expunge reconcile on IDLE wake; skip SEARCH only when local count is strictly behind server.
  • FE: coalesce list refresh 800ms; drop infinite-query 30s poll after warmup; periodic SSE fallback 60s.
  • Sync: inboundFlagSyncLimit 400 (was 2000).
  • Build: composite Dockerfile injects AppVersion; package-lock.json aligned to 3.1.7.
  • CI Playwright: E2E runs on GitHub-hosted only (fork PRs); self-hosted skips Playwright (no sudo for --with-deps).

Fixed

Mono / MonoPro Login — IMAP Network Errors as «invalid credentials»

Production Mono and MonoPro showed invalid credentials while sync/IDLE for the same mailbox kept working. Logs showed connection reset, deadline exceeded, and use of closed network connection against the IMAP host on :993.

Root cause:

  • tryIMAPLogin treated any Login/Authenticate failure as a wrong password (including TCP resets) and returned early.
  • For SSL it also probed STARTTLS and plain on port 993, amplifying failed connections under IDLE load.
  • Offline bcrypt fallback used UPDATE admins only — Mono often had no admin row, so fallback always became 401.

Fix:

  • Classify imapTransient vs imapAuthRejected (unit-tested).
  • IMAPS (ssl): TLS only, one retry, 15s dial timeout, TLS ServerName for SNI.
  • Upsert bcrypt password cache on successful IMAP login (CreateAdmin / UpdateAdminPassword).
  • Offline fallback: bcrypt or decrypted stored account password.
  • If IMAP is down and offline auth is unavailable → 503 mail server temporarily unavailable (not 401).

Affects Mono and MonoPro editions only (Unified still uses DB bcrypt).

On successful IMAP login, the stored account password is refreshed when it differs from the submitted password (keeps sync workers in sync after mailbox password changes).

Server Expunge → Local Delete Sync

Emails deleted/expunged on the IMAP server stayed visible in the UI: incremental sync only looked for new UIDs and never reconciled missing ones. Inbound flag FETCH also ignored misses.

Fix:

  • After folder sync (and when a folder is empty), reconcile local UIDs vs UID SEARCH ALL when the local count is ahead of the server; purge missing messages + body files.
  • During inbound flag sync, UIDs not returned by FETCH are treated as expunged and deleted locally (non-Gmail).
  • Broadcast SSE email_deleted; frontend listens and refreshes the list.
  • Gmail skipped for hard-delete reconcile (label model).

Changed

CI — Self-Hosted Runners

All CI jobs (backend, frontend, e2e-unified) now use runs-on: [self-hosted, linux, x64] instead of ubuntu-latest.

e2e-unified starts Postgres via docker run on port 54329 (no GitHub services: containers — unreliable on bare self-hosted runners).

Fixed (ops / hygiene)

  • CORS: main corsWrapper now sets Access-Control-Allow-Credentials: true when an Origin is allowlisted (aligned with MCP SetCORSHeaders).
  • DATABASE_URL built from POSTGRES_* env vars now URL-encodes user/password via net/url.
  • Stop tracking backend build binaries and SQLite WAL/SHM artifacts; expand .gitignore.

Notes

  • Failed to find Server Action "…" in UI logs is a Next.js deploy/cache mismatch (stale browser or UI container vs API), not the IMAP login bug. Hard refresh or recreate the UI container after pull.
  • Go module toolchain: 1.26.5 (from post-3.1.6 tree; not previously called out).
  • Follow-up (need EML samples): some marketing/transactional emails still strip confirmation buttons; some table-based layouts still render incorrectly — to be diagnosed from provided .eml files.

v3.1.6

Choose a tag to compare

@max-ramas max-ramas released this 11 Jul 09:38

Changelog

[3.1.6] — 2026-07-11

Fixed

Attachment Upload — Empty UUID in PostgreSQL

Uploading files for email compose failed silently on PostgreSQL (Unified/MonoPro). The handler set EmailID: "" which PostgreSQL rejected with invalid input syntax for type uuid. The user saw no error because the frontend catch block only logged in development mode.

Fix: EmailID and AccountID now use sentinel UUID 00000000-0000-0000-0000-000000000000 instead of empty string. Frontend catch always shows error toast. Backend added h.CAS == nil guard (503 instead of nil panic), and all per-file errors logged via slog.Info.

Gmail Sync — Audit Fixes

Code audit revealed several issues in the dedup and cleanup pipeline:

  • GetEmailByMsgIDAccount error swallowed — transient DB error caused dedup miss → duplicate email rows. Now logs error and saves as new rather than silently skipping.
  • CleanupGmailDuplicates error handling — folder-path query error silently swallowed; orphaned junction rows not cleaned for deleted duplicates. Both fixed.
  • ApplyServerEmailFlags error ignored — flag merge result silently dropped on write failure. Now logged.
  • Unnecessary COUNT query in SQLite BackfillGmailLabels removed.

PostgreSQL Flag Toggles — Missing is_dirty_locally

ToggleFlagEmail, TogglePinEmail, ToggleMuteEmail omitted is_dirty_locally = true in PostgreSQL — flags/pins/mute silently reverted on next sync cycle. Fixed. Affected only Unified and MonoPro editions.

v3.1.5

Choose a tag to compare

@max-ramas max-ramas released this 07 Jul 08:13

Changelog

[3.1.5] — 2026-07-07

Added

Gmail Labels Support — Multi-Label Deduplication

Gmail doesn't have real folders — only labels. One email appears in multiple "folders" with different UIDs. Previously each label was stored as a separate email row with independent read/delete state, causing inconsistent views across the UI.

Detection: Gmail accounts are detected via IMAP X-GM-EXT-1 capability on connect. The is_gmail flag is persisted and used throughout the sync pipeline.

Dedup: ProcessMessageStreamToFolder checks GetEmailByMsgIDAccount before saving — same email in multiple labels becomes one row, with labels tracked via email_labels_junction table.

Flag sync: syncFlagsGmail applies flags globally via UID STORE FLAGS (no per-folder SELECT). Includes \Seen, \Flagged, and \Answered.

Move sync: syncMoves updates junction table after IMAP MOVE — removes source label, adds target.

Skipped folders: [Gmail]/All Mail and [Gmail]/Trash excluded from sync (redundant — all emails covered by other labels).

Backfill: backfillGmailAccounts() marks existing accounts with imap.gmail.com as Gmail on startup.

Cleanup: CleanupGmailDuplicates merges pre-migration duplicate rows (same msg_id across folders) once per account.

API: GetEmails routes Gmail folder queries through GetEmailsByLabel — JOINs email_labels_junction instead of filtering by folder_id.

Schema: Migration 025 adds accounts.is_gmail, email_labels_junction table. No FK constraints (PostgreSQL partitioned tables limitation — application-level cascade in DeleteEmail).

Added

Command Palette Shortcuts

  • Cmd+K / Ctrl+K: opens Command Palette
  • Cmd+, / Ctrl+,: navigates to Settings
  • Shortcuts displayed in Keyboard Shortcuts modal (Shift+?) header and navigation category
  • Added to PREVENT_DEFAULT_KEYS to prevent browser focus-search/settings hijacking

Fixed

Email Viewer — 3-Second Text Stub Before Body Loads (All Editions)

Clicking an email showed the plain-text snippet for 3–4 seconds before the full HTML body and thread interface loaded. Only the pulse skeleton should have been visible during loading.

Root cause: Commit cb34fd97 introduced a setQueryData cache seed with partial list data (snippet, no body/html) combined with refetchOnMount: false — React Query believed it had data, skipped the fetch. Body arrived accidentally via mark-read invalidateQueries 3 seconds later.

Fix: Removed setQueryData seed (header renders from selectedEmail list data, not emailQuery.data). Removed refetchOnMount: false. Previously viewed emails still served from cache within staleTime: 60_000.

CAS Storage — Nil Pointer Panic in Production

ProcessMessageStreamToFolder panicked with nil pointer dereference on f.CAS.Save() for emails with attachments. Go interface nil trap.

Fix: var casIf sync.CASStore; if cas != nil { casIf = cas } — explicit nil check before interface assignment.

Login — DB Error vs Invalid Credentials

HandleLogin returned generic 401 for both "wrong password" and "database connection failure". AuthGuard 401 response caused logout on transient DB errors.

Fix: GetAdminByEmail uses errors.Is(err, pgx.ErrNoRows). HandleLogin returns 503 on DB errors, 401 only for auth failures. AuthGuard retries verify once after 500ms on 401.

Gmail Label Pagination — Missing X-Next-Cursor

GetEmails Gmail routing sent responses without X-Next-Cursor header — frontend useInfiniteQuery stopped fetching after first page (35 emails with unread filter).

Fix: Gmail label path now returns offset-based cursor via standard Cursor{DateSent: time.Unix(offset+limit), ID} format. Frontend ParseCursor round-trips correctly. Provides correct pagination with zero frontend changes.

PostgreSQL — Schema Compatibility Fixes

  • email_labels_junction FK removed — partitioned emails table doesn't support FK references. Application-level cascade in DeleteEmail instead.
  • emails_msg_id_account_key unique index removed — can't create on partitioned table without partition key.
  • COALESCE(is_gmail, false) → COALESCE(is_gmail, 0) — type mismatch (integer vs boolean).
  • UpdateAccountIsGmail passes 0/1 instead of bool to INTEGER column.
  • UpsertEmailLabels passes 0/1 instead of bool to INTEGER system column.
  • GetAccount/GetAccountCredentials now scan is_gmail in both backends.

Gmail Flag State Reversion — Read/Unread Toggle Fails

When a Gmail email was marked as read, the flag appeared to change but later reverted. Root cause: dedup block applied flags from each label's IMAP fetch independently — if a second label's fetch showed \Seen=false (Gmail hadn't propagated the flag yet), ApplyServerEmailFlags overwrote is_read=true back to false.

Fix: Dedup block now merges flags — takes the most permissive state across existing row + current fetch. \Seen never reverts to unread. \Flagged and \Answered treated identically.

Redis Cache — Email Listing Stuck at First Page

Redis tryCache returned cached responses without X-Next-Cursor headers, breaking infinite scroll pagination for all Redis-backed editions (MP, U). The cache body was stored, but per-request computed headers were not.

Fix: Removed tryCache and cacheSet from GET /api/emails handler. Never cache paginated API responses that depend on per-request computed headers.

SQLite Migration — DO $$ Block Incompatibility

Migration 025 used PostgreSQL DO $$ syntax for column addition. SQLite rejected it with "near DO: syntax error", crashing backend bootstrap for Mono edition.

Fix: Created 025_gmail_labels_mono.sql with plain ALTER TABLE ADD COLUMN (compatible with SQLite's isBenignSQLiteMigrationError handler).

Gmail Detection — CAPABILITY Miss

detectGmail relied solely on X-GM-EXT-1 in c.Caps(), but some Gmail servers delay announcing this capability until after SELECT. Accounts were silently not detected as Gmail.

Fix: Added IMAP host fallback (imap.gmail.com) and persisted-flag re-check. Detection is now two-tier: capability first, host heuristic second.

Gmail — Inbound Flag Reversion

syncInboundFlags processed Gmail accounts with per-folder FETCH + ApplyServerEmailFlags. If one label's flags were stale (Gmail propagation delay), server-side state overwrote locally-synced flags — is_read=true reverted to false.

Fix: For Gmail accounts, syncInboundFlags now merges flags — MAX(local, server). \Seen, \Flagged, \Answered never downgrade local state. Dirty emails (is_dirty_locally=1) excluded by existing query filter.

Gmail — Locale-Dependent All Mail/Trash Skip

isGmailSkippedFolder matched English folder names only ("all mail", "trash"). On non-English Gmail locales (Russian Вся почта/Корзина, German Alle Nachrichten/Papierkorb, etc.) both folders were synced — creating guaranteed duplicate rows for every email.

Fix: Switched to IMAP attributes (\All, \Trash, \Noselect) — locale-independent per RFC 6154. Fallback to known localized names (English, Russian) for servers not returning attributes.

Gmail — Label Backfill

Existing Gmail emails synced before label support had no entries in email_labels_junction. GetEmailsByLabel returned empty → folders appeared empty despite having emails.

Fix: BackfillGmailLabels — one-time operation populates email_labels_junction from emails.folder_id → folders.path for all Gmail accounts. Runs once per account via system_settings flag.

Gmail — Silent Label Write Failures

UpsertEmailLabels errors were silently ignored in dedup block and new-email path. If the junction table didn't exist or a write failed, labels were lost with no diagnostic.

Fix: All UpsertEmailLabels/GetGmailLabels calls now log errors with context (emailID, folder, error).

Attachment Upload — Empty UUID in PostgreSQL

Uploading files for email compose failed silently on PostgreSQL (Unified/MonoPro). The handler set EmailID: "" which PostgreSQL rejected with invalid input syntax for type uuid. The user saw no error because the frontend catch block only logged in development mode.

Fix: EmailID and AccountID now use sentinel UUID 00000000-0000-0000-0000-000000000000 instead of empty string. Frontend catch always shows error toast. Backend added h.CAS == nil guard (503 instead of nil panic), and all per-file errors logged via slog.Info.

Changed

  • Backend Dockerfile: removed stale ARG PUBLIC_KEY="", removed schema.sql copy (Mono-only).
  • License ping: LICENSE_SERVER_URL env override removed — uses hardcoded https://license.rms-ds.com.
  • Mono edition: InitHK(), periodic update pings with latest_version/release_notes restored.

v3.1.4

Choose a tag to compare

@max-ramas max-ramas released this 03 Jul 18:13

Changelog

[3.1.4] — 2026-07-03

Fixed

Email Viewer — 3-Second Text Stub Before Body Loads

Clicking an email showed the plain-text snippet for 3–4 seconds before the full HTML body and thread interface loaded. Only the pulse skeleton should have been visible during loading.

Root cause: A setQueryData call seeded React Query's cache with partial list-item data (email metadata with snippet but without body, html, or attachments). Combined with refetchOnMount: false on useEmail, the query believed it already had data — isLoading stayed false, EmailBody rendered {body || snippet} showing the snippet. The real body arrived only accidentally via the mark-read invalidateQueries 3 seconds later.

Fix: Removed setQueryData cache seed in handleSelectEmailList and removed refetchOnMount: false from useEmail. New emails always trigger an immediate body fetch while maintaining cache for previously viewed emails within staleTime: 60_000.

CAS Storage — Nil Pointer Panic in Production

ProcessMessageStreamToFolder panicked with nil pointer dereference on f.CAS.Save() for emails with attachments. Go interface nil trap: a nil *attachment.CASStorage passed to an interface{} parameter wraps as non-nil.

Fix: var casIf sync.CASStore; if cas != nil { casIf = cas } — explicit nil check before passing to interface.

Login — DB Error vs Invalid Credentials

HandleLogin returned generic 401 for both "wrong password" and "database connection failure". AuthGuard responded to 401 with a redirect to /login — transient DB errors caused logout.

Fix: GetAdminByEmail uses errors.Is(err, pgx.ErrNoRows). HandleLogin returns 503 on DB errors, 401 only for genuine auth failures. AuthGuard retries verify once after 500ms on 401.

Changed

  • Dockerfiles: English comments throughout (frontend + backend).
  • Mono license pinger: Hardcoded https://license.rms-ds.com without env override. Update notifications (latest_version, release_notes) + InitHK() restored for Mono edition.

v3.1.3

Choose a tag to compare

@max-ramas max-ramas released this 30 Jun 23:17

Changelog

[3.1.3] — 2026-07-01

Changed

IMAP \Seen Flag — Bidirectional Sync

The IMAP \Seen flag was completely ignored. Emails always inserted as is_read=false, and local read/unread changes never pushed to server.

IMAP → RMS: ProcessMessage and ProcessMessageToFolder now parse msg.Flags and set email.IsRead = true when \Seen is present.
Atomic ON CONFLICT: CASE WHEN is_dirty_locally THEN emails.is_read ELSE EXCLUDED.is_read END — local changes protected, server state respected.
RMS → IMAP: syncFlags() in worker — queries GetDirtyEmails (LIMIT 500), groups by read/unread, sends batched IMAP STORE +FLAGS/-FLAGS \Seen (200 UIDs/batch). ClearDirtyFlag resets after success.

Bulk-by-Filter — Folder Name → UUID Resolution

perAccountFolder = "INBOX" was passed as folder NAME to buildFilterWhere which compared it against folder_id (UUID column) for non-unified accounts. Result: 0 affected rows, operation appeared successful but did nothing.

Fixed: Multi-account and single-account paths in bulkActionByFilter + runBulkFilterOp now resolve folder names to UUIDs via GetFolders for each account before passing to filter-based operations.

Fixed

Login — First Attempt Kicked Out

AuthGuard GET /api/auth/verify failed on first navigation after login — token was in localStorage but verify returned 401. Race condition between Next.js client-side navigation and axios interceptor picking up the token.

Fixed: AuthGuard now retries verify once after 500ms delay on 401 before redirecting to login.

Email Viewer + List Flicker (~3s after load)

SSE delivered queued events on initial connection, triggering scheduleListRefresh() and invalidateQueries(["email", id]). Both email list and viewer refetched — data unchanged but React re-rendered.

Fixed: 5-second warmup window via warmupUntil ref — scheduleListRefresh and refreshListNow skip SSE-triggered refreshes for first 5 seconds after mount. useEmail query: removed placeholderData: keepPreviousData, added refetchOnMount: false, raised staleTime: 60_000.

Group Color Missing for Long Names

Flex container overflow-hidden + long group name without truncate → color dot pushed outside visible area and clipped.

Fixed: shrink-0 on arrow/dot/count, min-w-0 truncate on name.

GetGroups — Correlated Subquery on Production

Correlated subquery per group scanned emails JOIN folders JOIN project_group_accounts WHERE group_id = pg.id — O(N×M) where N=groups, M=250K emails. Instant on local (50 emails), 30+ seconds on production.

Fixed: Replaced with CTE WITH inbox_unread AS (...) filtering to ~500 rows first, then LEFT JOIN with groups. Single table scan.

shiftPGPlaceholders — Parameter Corruption

String replacement $10 → $11 then $1 → $2 in $11 → $21. Intermediate marker pattern (__PGH__N__) eliminates double-replacement.

Bulk-by-Filter — Snoozed Emails Counted in Unified

buildFilterWhere unified subquery missing snooze_until filter — count included snoozed emails, list excluded them → mismatch.

Fixed: AND (e2.snooze_until IS NULL OR e2.snooze_until <= NOW()) added to unified subquery in both SQLite and PostgreSQL.

Sync Worker — 250K Object Memory Accumulation

messages = append(messages, batchMsgs...) in batching loop — dead code (never read after loop, return nil before access). Held 250K FetchMessageBuffer objects during full sync.

Fixed: removed the append.

Added

  • GetDirtyEmails / ClearDirtyFlag: store methods for \Seen flag sync
  • SyncStore: both methods added to sync package interface
  • AuthGuard retry: visual feedback during token verification grace period

v3.1.2

Choose a tag to compare

@max-ramas max-ramas released this 30 Jun 15:39

Changelog

[3.1.2] — 2026-06-29

🎉 New Product Launch: RMS Mail Mono Pro Edition

The Mono Pro Edition has been officially released as a standalone commercial product. It combines the single-tenant isolation model of Mono with the enterprise-grade infrastructure of Unified (PostgreSQL, Redis, Asynq).

  • Standalone Product Architecture: Fully separated from the Unified Edition with dynamic database isolation (geomail_mp and Redis index 1) to prevent cross-contamination.
  • Licensing & Administrator Control: Enforced rigorous limits for unactivated instances. Unactivated Mono Pro instances are strictly locked to a single administrator account until a valid commercial license is provided.
  • Admin Panel Separation: The Admin Panel is now a dedicated, independent interface (/admin) accessible only to users with the is_admin flag, providing a foundation for advanced team, security, and license management.
  • Dedicated Deployment Pipeline: Released standalone build, run, and publish scripts (run-mp.sh, beta-mp.sh, bp-mp.sh) and docker-compose configurations specifically tailored for the Mono Pro lifecycle.

🔐 Admin Panel — Edition Isolation

  • /api/admin/* routes now gated to Mono Pro and Teams only (IsMonoPro() || IsTeams()). Previously registered for all editions.
  • Frontend guards: Admin page redirects Mono and Unified away. Sidebar «Admin Panel» button hidden in M/U.

👥 Admin Users Table — Role Column

  • AdminUser struct now includes role field (admin / user).
  • GetAdminUsers populates role from the users table via GetUserByEmail.
  • UpsertUser + GetUserByEmail added to EntityStore (postgres + sqlite).
  • Login + HandleGetMe: auto-create user record in users table with correct role on first activity.

🕐 LAST SEEN AT — Real Login Time

  • GetAccounts() now includes last_seen_at in SELECT (was missing — always showed zero time).
  • HandleGetMe updates last_seen_at = NOW() on account (Mono Pro / Teams only).
  • UpdateAccountTimestamp extended with "last_seen" case (postgres).

🔔 Notifications — Admin Only in Mono Pro

  • NotificationCenter in sidebar and About tab hidden for non-admin users in Mono Pro.
  • Check-updates button in About tab similarly restricted.

🗑️ Bulk & Single Delete — Trash-Aware

  • Emails already in Trash → hard delete (BulkDeleteEmails / DeleteEmail).
  • Emails not in Trash → move to Trash (BulkMoveAndEnqueue).
  • Previously: delete from Trash was a silent no-op (move to Trash again).

🐛 Bug Fixes

  • FTS Email Search Optimization (All Editions): Fixed a critical issue where searching by email address (e.g., test@example.com) failed or returned no results.
    • In SQLite (Mono), special characters like @ caused FTS5 syntax errors.
    • In PostgreSQL (Unified & Mono Pro), email addresses were parsed as monolith email tokens, preventing partial substring matches.
    • Solution: The FTS indexing and search queries now consistently sanitize and split email addresses by punctuation (@, ., <, >), ensuring robust, high-performance partial and exact matching across both database engines.
  • BulkMoveAndEnqueue — INSERT column mismatch (PostgreSQL): INSERT INTO imap_move_queue had 8 columns but only 6 values — caused SQLSTATE 42601 on every bulk delete/archive/move. Fixed by adding missing retry_count and created_at parameters.
  • README Feature Matrix: Removed OAuth 2.0 from Mono Pro column (OAuth is Unified/Teams only).
  • Dead code cleanup: Removed patch_auth.go (orphaned test file at project root).
  • Edition guard hygiene: HandleGetMe no longer calls UpsertUser / GetUserByEmail in Mono edition (unnecessary — all Mono users are admin). last_seen update restricted to MP/T. SQLite UpdateAccountTimestamp reverted to default: return nil for unused fields.

v3.1.1

Choose a tag to compare

@max-ramas max-ramas released this 27 Jun 20:09

Changelog

[3.1.1] — 2026-06-27

Backend Architecture Refactoring — SOLID / DRY / KISS / YAGNI

Interface Segregation (ISP)

  • internal/api/store_interfaces.go: 7 segregated interfaces replacing the 100-method Store monolith.
    • EmailReader (31 methods), EmailWriter (41), AccountStore (17), FolderStore (8), EntityStore (templates/labels/rules/contacts/groups/users/comments), AdminStore (admin/AI/MCP/webhooks/Telegram), SystemStore (migrations/FTS/queue/jobs).
    • Composite Store interface preserved for backward compatibility.
  • internal/api/handlers.go: Handler now exposes segregated fields (Emails, Writer, Accounts, Folders, Entities, Admin, System) alongside legacy Store.

Single Responsibility (SRP) — Storage Monoliths Split

  • internal/store/postgres/: storage.go 3980 → 561 lines. New files: emails.go (2020), accounts.go (387), org.go (396), integrations.go (440), folders.go (137), labels_tags.go (82), attachments.go (55).
  • internal/store/sqlite/: mirror split (4386 → 465 lines).
  • internal/store/shared/crypto.go: extracted EncryptPassword / DecryptPassword shared between both implementations.
  • WithTx() added to both postgres.Storage and sqlite.Storage for atomic cross-file transactions.

DRY — Duplication Elimination

  • internal/mime/qp.go: unified DecodeQuotedPrintable — removed 3 duplicate implementations (api, sync, dead code in handlers.go).
  • internal/api/email_action_handlers.go: toggleEmail helper — 3 toggle functions replaced with single-argument wrappers.
  • internal/store/postgres/email_columns.go: emailColumns / emailColumnsPrefixed constants — 10 duplicated column lists → 2 constants.

OCP — Registry Pattern

  • internal/api/email_action_registry.go: emailActions map (11 actions) registered in init(). HandleEmail switch-case replaced with O(1) map lookup. Thread-safe: write-once in init(), read-only at request time.

Dependency Inversion (DIP)

  • internal/sync/interfaces.go: CASStore interface (1 method) + AIProvider interface (3 methods) defined in consuming package.
  • Fetcher and Manager now depend on interfaces instead of concrete *attachment.CASStorage and *ai.Gateway. Testable without real file-system or AI.

YAGNI Cleanup

  • Removed: internalDecodeQP + unhexDigit (dead code, −32 lines).
  • Removed: BulkToggleFlagEmails from interface + both implementations + mock (−28 lines).
  • Removed: sqlite.Storage.Query/QueryRow/Exec — raw SQL access not in contract (−15 lines).
  • Removed: 5 backup files storage.go.bak* (−775KB).

GlitchTip / Sentry Error Monitoring (Backend + Frontend)

  • internal/sentry/ (new package): Init(), IsEnabled(), Flush(), CaptureException(), Recover(), Go() — all no-ops when SENTRY_DSN is unset, zero overhead.
  • cmd/server/main.go: sentry.Init() after log setup, defer sentry.Flush() on shutdown, CaptureException in HTTP panic recovery.
  • internal/api/errors.go: WriteInternalError → sentry.CaptureException(err) — all 500 errors reported with request context.
  • internal/sync/imap_connect.go: reportProvisionalSyncError → sentry.CaptureException(err) — IMAP/auth errors tracked per account.
  • Frontend Sentry SDK (@sentry/nextjs): sentry.client.config.ts + sentry.server.config.ts + sentry.edge.config.ts — conditional init via NEXT_PUBLIC_SENTRY_DSN.
  • next.config.ts: withSentryConfig() — conditional wrapping, source maps upload at build time (SENTRY_AUTH_TOKEN).
  • App Router error boundaries: src/app/[locale]/error.tsx + src/app/global-error.tsx — Sentry.captureException(error) on SSR/CSR failures.
  • sentry.client.config.ts: tracePropagationTargets: [/^\/api/] — auto-propagates sentry-trace header to backend, linking frontend errors with backend traces.
  • Docker: docker-compose files + .env files updated with SENTRY_DSN / SENTRY_ENVIRONMENT / NEXT_PUBLIC_SENTRY_DSN variables. Frontend Dockerfile accepts SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKEN as build ARGs for source map upload.
  • GlitchTip compatible: uses standard Sentry SDK (DSN format identical). No proprietary features.

Auth — Login Second-Attempt Fix

  • internal/api/middleware/auth.go: extractToken() priority changed — Authorization header (localStorage) now checked before httpOnly cookie.
    • After login, the frontend stores the fresh token in localStorage synchronously and sends it via Authorization header.
    • Cookie was previously checked first, so a stale/expired cookie from a previous session could cause the first post-login request to fail with 401 — forcing a second login attempt.
    • Header-first ensures the freshest token is always used; cookie remains as fallback for page reload survival.