Skip to content

Mail

Responsibility

Mail integrates IMAP/SMTP accounts, local message indexing, folders, search, tags, saved views, drafts, attachments, replies, contact lookup, AI drafting and entity extraction. Provider credentials remain local per machine.

The strictly typed frontend/src/features/mail/ domain owns mailbox page composition, mail components, tags and saved-view hooks, and their tests. Application routes consume its public lazy entry without eagerly loading the mailbox or composer. Shared HTTP adapters keep the existing API contracts. The BlockNote mail editor and its adapter belong to this domain. Settings consumes the editor through its explicitly reviewed public entry; there are no copied implementations or compatibility facades. Moving ownership does not change sending, draft saving, folder identity, privacy, or provider operations.

Synchronization

Account integrations describe protocol and OAuth/credential references. A full or incremental sync reads provider messages, normalizes identifiers and MIME content, and writes local index rows. IMAP IDLE workers hold one connection per eligible account and trigger incremental refresh when the server announces changes.

Google and Microsoft provider adapters expose the same typed Gnosi message, attachment, draft, label, and send boundaries. Dynamic SDK payloads are narrowed inside each adapter; the only local typing exceptions are the exact untyped third-party discovery calls, never the service API consumed by Gnosi. OAuth refresh accepts only a concrete non-empty token before persisting it. Google's untyped credential constructor and refresh call are isolated and documented inside that adapter; IMAP and SMTP receive standard-library connection types at the XOAUTH2 boundary.

Batch ingestion uses savepoints so a malformed message cannot roll back earlier messages. Message and thread identity must remain stable across repeated syncs. Per-thread UI metadata is persisted as a validated JSON object under the local secrets/data boundary. Read-modify-write operations share one lock so concurrent tabs cannot silently discard each other's fields; malformed root or thread entries fail closed without affecting valid records. Folder names are provider values; the UI translates known semantic folders without changing persisted comparison values.

The legacy Gmail-to-Vault exporter narrows discovery payloads at its service boundary, requires a configured Mail directory before any filesystem access, and deduplicates by provider message id. Multipart text, HTML, categories, labels and attachment presence retain their historical Markdown/frontmatter representation; a missing Vault fails closed without creating files elsewhere. Every synchronized note retains database_table_id: mail, and frontmatter is serialized through yaml.dump rather than hand-built string escaping.

Conversation reading and message previews

Gmail conversations use the provider's \All mailbox, discovered from IMAP flags even when its name is localized. Header metadata before and after an IMAP body literal is preserved, including thread identity, dates and sent labels. Message reads retain both account and folder because IMAP UIDs are scoped to a mailbox. The viewer resolves the thread from the loaded message and expands and loads its newest item, including sent replies.

Successful sends invalidate cached mail lists and counts; the page refreshes both after the composer completes. The list's delayed hover preview loads the complete plain-text or sanitized HTML body without marking it read. Its portal stays inside the viewport, remains open while the pointer is over it, and supports vertical scrolling, navigation keys and Escape. Loading is deferred until hover, with cancellation and retry. HTML frames size from body content rather than their own viewport, avoiding recurring height growth.

Keyboard reading

Arrow Up and Arrow Down move through the displayed message groups, including when a message viewer is open. A focused row opens after 500 ms without another navigation step; intermediate rows are neither opened nor marked read. Clicking or pressing Enter opens immediately. The existing viewer marks unread mail read after the message loads successfully, preserving its account and folder identity. Read messages remain visible in the default list; explicit unread filters and saved-view filters retain their requested behavior.

Focus follows the message identity rather than a mutable row index. Background read-state updates do not redirect pending navigation to the previously opened message. Changing account, folder, view, tags or search cancels the pending opening, as do composing, opening a menu, focusing an input, leaving the window or removing the target message. Keyboard batch selection and deletion remain available. The separate hover preview keeps its own scrolling keys and does not mark messages read.

MIME and content safety

flowchart LR
    Raw["Provider message"] --> Decode["Header and transfer decoding"]
    Decode --> MIME["MIME tree normalization"]
    MIME --> CID["Inline CID resource mapping"]
    MIME --> Attach["Attachment descriptors"]
    MIME --> Body["Sanitized text and HTML"]
    Body --> Index["Local message index"]

HTML is sanitized before rendering. Inline CID images are resolved against the correct MIME part and preserved when quoted content is included in replies. Remote images and attachments remain explicit resources rather than arbitrary HTML access to local paths.

The inline-image boundary uses typed MIME asset descriptors and a common Message root for text, related and mixed trees. It accepts only decoded byte payloads, normalizes optional content types, and leaves asset URLs unchanged when no active Vault or materialized file is available. The same MimeAsset and InlineImage contracts flow unchanged through Gmail, Microsoft Graph and SMTP senders. Quoted assets are promoted to inline images by explicitly filling every required field and generating a fresh Content-ID.

Compose and send

The block editor creates a draft representation that is converted to mail-safe HTML and text. Sender identity, recipients, reply headers, quoting, attachments, and provider account are validated server-side. Draft save and send are different effects; sending crosses an external boundary and returns provider diagnostics on failure.

Local relational state

The mail database stores messages, tags, message-tag associations, and saved views. Saved views contain visible fields, typed filters, logic, grouping, sorting, and available actions as JSON within SQLite rows. Create and partial-update schemas remain separate Pydantic contracts so an update may omit its name without weakening the create requirement; their HTTP and OpenAPI shapes remain compatible with 2.x clients.

Invariants

  • Sync is idempotent for a provider message identifier.
  • A failed message uses a savepoint and does not abort the account batch.
  • Tags and saved views are local application state, not provider labels unless an explicit mapping exists.
  • Reply headers preserve thread identity.
  • CID references point to the correct inline part after quoting or forwarding.
  • Deleting or moving a provider message requires the authenticated account and a validated folder/message target.
  • Secret values never enter message rows or frontend configuration responses.

Verification focus

Test MIME decoding, HTML sanitization, CID rendering and replies, ingestion savepoints, tags, view filters, drafts, identity resolution, and a real or stubbed provider send. Playwright verifies paste, compose, and quoted reply behavior.