Repository navigation
Releases: max-ramas/rms-mail-public
Release list
Release v3.1.10: Audit remediation
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-Keyonly; SSE endpoint URL uses opaquesessionId(no API key in query);last_used_attouched on successful auth. - Rate limits: Redis limiter degrades to in-memory on Redis errors; MCP
/mcp/sse+/mcp/messages30/min/IP; global API 60/min/IP (health skipped);/metricsbehindAdminOnly. - 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), notemails-infinite. refreshMailInboxuses 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 withReturnSpecialUse. - MCP store parity tests (SQLite always; Postgres
integration+DATABASE_URL). ToggleMCPKeymoved tointegrations.go;ports.JobStorebreakssync/job_worker→apiimport.- 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 viaUpdateAccountwhen the row exists.
v3.1.9
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.
RefreshUnreadCountsnow 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/textfromv0.39.0tov0.41.0to resolveGO-2026-6629vulnerability.
v3.1.8
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'sGetAttachmentByHash. - Security: Upgraded
golang.org/x/textfromv0.38.0tov0.39.0to fix theGO-2026-5970infinite loop vulnerability.
v3.1.7
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_EMAILrequired at startup; removed first-registered-user admin fallback. - Setup cookie:
HandleSetupusesrms_tokenviaSetTokenCookie(wasauth_token). - Rate limit: honor
X-Forwarded-For/X-Real-IPonly whenRemoteAddris inTRUSTED_PROXIES. - MCP auth: validate via
key_hash(SHA-256 + constant-time); encrypted key still stored for UI view. - Camo:
CAMO_HMAC_KEYrequired in production for U/MP; Mono may omit it and derive fromENCRYPTION_KEYS. - Compose U/MP: backend
exposeonly (proxy via frontend:3000); keepuser: "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:
inboundFlagSyncLimit400 (was 2000). - Build: composite
DockerfileinjectsAppVersion;package-lock.jsonaligned 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:
tryIMAPLogintreated 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 adminsonly — Mono often had no admin row, so fallback always became 401.
Fix:
- Classify
imapTransientvsimapAuthRejected(unit-tested). - IMAPS (
ssl): TLS only, one retry, 15s dial timeout, TLSServerNamefor 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 ALLwhen 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
corsWrappernow setsAccess-Control-Allow-Credentials: truewhen an Origin is allowlisted (aligned with MCPSetCORSHeaders). DATABASE_URLbuilt fromPOSTGRES_*env vars now URL-encodes user/password vianet/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
.emlfiles.
v3.1.6
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:
GetEmailByMsgIDAccounterror swallowed — transient DB error caused dedup miss → duplicate email rows. Now logs error and saves as new rather than silently skipping.CleanupGmailDuplicateserror handling — folder-path query error silently swallowed; orphaned junction rows not cleaned for deleted duplicates. Both fixed.ApplyServerEmailFlagserror ignored — flag merge result silently dropped on write failure. Now logged.- Unnecessary COUNT query in SQLite
BackfillGmailLabelsremoved.
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
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_KEYSto 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_junctionFK removed — partitionedemailstable doesn't support FK references. Application-level cascade inDeleteEmailinstead.emails_msg_id_account_keyunique index removed — can't create on partitioned table without partition key.COALESCE(is_gmail, false)→COALESCE(is_gmail, 0)— type mismatch (integer vs boolean).UpdateAccountIsGmailpasses0/1instead ofboolto INTEGER column.UpsertEmailLabelspasses0/1instead ofboolto INTEGERsystemcolumn.GetAccount/GetAccountCredentialsnow scanis_gmailin 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="", removedschema.sqlcopy (Mono-only). - License ping:
LICENSE_SERVER_URLenv override removed — uses hardcodedhttps://license.rms-ds.com. - Mono edition:
InitHK(), periodic update pings withlatest_version/release_notesrestored.
v3.1.4
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.comwithout env override. Update notifications (latest_version,release_notes) +InitHK()restored for Mono edition.
v3.1.3
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\Seenflag syncSyncStore: both methods added to sync package interface- AuthGuard retry: visual feedback during token verification grace period
v3.1.2
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_mpand Redis index1) 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 theis_adminflag, 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
AdminUserstruct now includesrolefield (admin/user).GetAdminUserspopulates role from theuserstable viaGetUserByEmail.UpsertUser+GetUserByEmailadded toEntityStore(postgres + sqlite).- Login +
HandleGetMe: auto-create user record inuserstable with correct role on first activity.
🕐 LAST SEEN AT — Real Login Time
GetAccounts()now includeslast_seen_atin SELECT (was missing — always showed zero time).HandleGetMeupdateslast_seen_at = NOW()on account (Mono Pro / Teams only).UpdateAccountTimestampextended with"last_seen"case (postgres).
🔔 Notifications — Admin Only in Mono Pro
NotificationCenterin 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
emailtokens, 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.
- In SQLite (Mono), special characters like
- BulkMoveAndEnqueue — INSERT column mismatch (PostgreSQL):
INSERT INTO imap_move_queuehad 8 columns but only 6 values — causedSQLSTATE 42601on every bulk delete/archive/move. Fixed by adding missingretry_countandcreated_atparameters. - 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:
HandleGetMeno longer callsUpsertUser/GetUserByEmailin Mono edition (unnecessary — all Mono users are admin).last_seenupdate restricted to MP/T. SQLiteUpdateAccountTimestampreverted todefault: return nilfor unused fields.
v3.1.1
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-methodStoremonolith.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
Storeinterface preserved for backward compatibility.
internal/api/handlers.go:Handlernow exposes segregated fields (Emails,Writer,Accounts,Folders,Entities,Admin,System) alongside legacyStore.
Single Responsibility (SRP) — Storage Monoliths Split
internal/store/postgres/:storage.go3980 → 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: extractedEncryptPassword/DecryptPasswordshared between both implementations.WithTx()added to bothpostgres.Storageandsqlite.Storagefor atomic cross-file transactions.
DRY — Duplication Elimination
internal/mime/qp.go: unifiedDecodeQuotedPrintable— removed 3 duplicate implementations (api,sync, dead code inhandlers.go).internal/api/email_action_handlers.go:toggleEmailhelper — 3 toggle functions replaced with single-argument wrappers.internal/store/postgres/email_columns.go:emailColumns/emailColumnsPrefixedconstants — 10 duplicated column lists → 2 constants.
OCP — Registry Pattern
internal/api/email_action_registry.go:emailActionsmap (11 actions) registered ininit().HandleEmailswitch-case replaced with O(1) map lookup. Thread-safe: write-once ininit(), read-only at request time.
Dependency Inversion (DIP)
internal/sync/interfaces.go:CASStoreinterface (1 method) +AIProviderinterface (3 methods) defined in consuming package.FetcherandManagernow depend on interfaces instead of concrete*attachment.CASStorageand*ai.Gateway. Testable without real file-system or AI.
YAGNI Cleanup
- Removed:
internalDecodeQP+unhexDigit(dead code, −32 lines). - Removed:
BulkToggleFlagEmailsfrom 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 whenSENTRY_DSNis unset, zero overhead.cmd/server/main.go:sentry.Init()after log setup,defer sentry.Flush()on shutdown,CaptureExceptionin 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 viaNEXT_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-propagatessentry-traceheader to backend, linking frontend errors with backend traces.- Docker: docker-compose files + .env files updated with
SENTRY_DSN/SENTRY_ENVIRONMENT/NEXT_PUBLIC_SENTRY_DSNvariables. FrontendDockerfileacceptsSENTRY_ORG/SENTRY_PROJECT/SENTRY_AUTH_TOKENas 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 —Authorizationheader (localStorage) now checked before httpOnly cookie.- After login, the frontend stores the fresh token in
localStoragesynchronously and sends it viaAuthorizationheader. - 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.
- After login, the frontend stores the fresh token in