Commit Graph
35 Commits
Author SHA1 Message Date
Anthony 299804459a Send attachments from Thunderbird through to Tuta
Wire the SMTP send path so that when Thunderbird hands the bridge a
`multipart/mixed` message its non-text parts are forwarded as real
Tuta attachments rather than dropped.

Parser side (mail/parser.rs)
* `ParsedMessage` grows an `attachments: Vec<Attachment>` field.
* `extract_multipart_body_and_attachments` walks every part, treats
  anything carrying `Content-Disposition: attachment` or a
  `name=` parameter (and not a text/* type) as a file, decodes
  base64 / quoted-printable, picks up the filename from
  Content-Disposition first then Content-Type's `name=`.

Send side (tuta.rs::send_mail_impl)
* `build_added_attachments` generates a per-file session key, uses
  the existing `BlobFacade::encrypt_and_upload_multiple` to ship the
  encrypted blob bytes, then assembles a `DraftAttachment` aggregate
  with the random aggregate `_id`s the instance mapper requires.
* After `DraftService` persists the draft, `build_attachment_key_data`
  reloads the resulting Mail, zips its `attachments[]` IdTuples with
  the session keys we kept locally, and produces
  `AttachmentKeyData[]` for both the top-level `SendDraftData` and
  its nested `parameters` aggregate (server reads from the latter).
* Empty-attachments path is a no-op, matching the previous behaviour.

Three new parser unit tests pin down the multipart-with-PDF happy
path, the filename-fallback to Content-Type `name=`, and that
`multipart/alternative` text parts are not misclassified as files.
2026-05-28 18:24:56 +02:00
Anthony c4ecc82daf Bump tuta-repo with attachment BlobGetIn fix
Picks up the SDK fix that supplies a random CustomId for each
BlobGetIn.blobIds[i]._id so the instance mapper accepts the serialised
body. Verified end-to-end: a 'test bridge :)' mail sent with a real PDF
attachment is now served over IMAP as multipart/mixed with the PDF as
a base64-encoded application/pdf part, magic bytes preserved.
2026-05-28 18:14:55 +02:00
Anthony d3f3f0e101 Track sdk-blob-download-and-decrypt in SDK_PRS
Document the new SDK branch that ports BlobFacade.downloadAndDecrypt
plus its MailFacade convenience and parser helper. Held — not
submitted upstream until the upstream blob branch lands.
2026-05-28 17:37:33 +02:00
Anthony cb6c761aac Defer attachment-bearing mails to the prefetch loop on event-bus CREATE
When the realtime path inline-decrypts a brand-new mail \"and\" its
MailDetailsBlob in the same batch, it used to render the RFC 2822
envelope right there and persist it to disk so the prefetch loop has
no work left for that mail. That shortcut is correct for mails with
zero attachments — but mails carrying attachments would end up cached
with a text/html-only body and no MIME parts for the attached files,
because the event-bus handler cannot synchronously fetch and decrypt
each File blob without making the WebSocket dispatch loop sleep.

Skip the inline-render when `mail.attachments` is non-empty so the
mail keeps `has_details=0` and the prefetch sweep picks it up,
emitting a proper `multipart/mixed` envelope through the same path
as historical mails.
2026-05-28 17:13:49 +02:00
Anthony cab8e0be0c Surface mail attachments over IMAP as multipart/mixed parts
When the prefetch loop loads a mail's body, also load every entity in
`mail.attachments`, decrypt the blob data with the new SDK helper and
embed each attachment as a base64 part of a `multipart/mixed` RFC 2822
message. Thunderbird now renders PDFs / images / etc. inline rather
than showing a body-only mail with no hint anything was attached.

* `mail_to_rfc2822` now takes a slice of (TutanotaFile, &[u8]) and emits
  a multi-part envelope when it's non-empty; the simple text/html case
  is unchanged. The boundary is derived from the mail's IdTuple so the
  cached .eml.enc bytes stay stable across rewrites.
* `MailBackend::load_attachments` is the new trait method; the
  TutaSession implementation loads each File via `crypto_client.load`
  (auto-decrypted via the file's own `_ownerEncSessionKey`) then asks
  the new `MailFacade::load_file_attachment_data` for the concatenated
  decrypted bytes.
* `prefetch_details` does a best-effort fetch — partial failure logs a
  warning and ships the body alone, on the assumption the user can
  re-open later and the next sweep will retry.

A new unit test asserts the multipart structure (boundary, body part,
attachment part with name/MIME/filename, closing boundary).
2026-05-28 16:52:27 +02:00
Anthony 56bd92787b Recover .eml from disk even when the metadata row says has_details=0
After a SQLite schema migration the `mails` table is dropped and
recreated empty; the encrypted `.eml.enc` files on disk survive that
migration (they are keyed by element id, not by row id). Phase 0 of the
syncer was gating the body recovery on `meta.has_details`, so the post-
migration boot would deliver a `mail_to_rfc2822(mail, None)`
headers-only body to IMAP even though the full body sat right there on
disk — a real mail's content quietly showed up as a placeholder in
Thunderbird until something forced a refetch.

Try `read_eml` unconditionally. When it returns the body, also flip
`has_details = 1` on the row so subsequent prefetch sweeps skip the
mail and the heal is permanent. Mismatch → still falls back to
headers-only as before. The extra read at boot is a `Path::exists()`
plus an AES decrypt per mail, negligible vs the network costs we
already pay.

While here, demote the misleading IMAP-fetch log: when `details` is
None but `rfc2822` is populated we *do* serve the real body, so log
"placeholder" only when `rfc2822` is also missing.

166 bridge lib tests pass.
2026-05-28 16:07:15 +02:00
Anthony 4bb07798dd Load draft bodies and stop spamming "No details for mail"
`load_mail_details_impl` now mirrors the TS `loadMailDetails` router:
when `mail.mailDetailsDraft` is set, call the new
`MailFacade::load_mail_details_draft` (sdk-mail-draft-details); when
`mail.mailDetails` is set, keep the existing blob path; otherwise return
`Ok(None)` — the legacy/malformed leaf the prefetch loop has to handle
anyway. Practical effect: the ~88 drafts on the test account that
previously logged "No details for mail" on every store change now decrypt
their body normally and surface it through IMAP FETCH.

The `Ok(None)` branch in `prefetch_details` was the secondary cause of
the log spam — with the prefetch loop now event-driven (Phase 2.5), every
`MailStore` bump re-queued the same 88 drafts because `has_eml` stayed
false. Persist a headers-only `.eml` and `mark_has_details = 1` on that
branch so the row is considered done; the next sweep skips it, and the
client at least gets the headers for a mail whose body we genuinely
cannot locate.
2026-05-28 15:51:14 +02:00
Anthony bb15bf744e Track sdk-mail-draft-details in the SDK integration
New SDK branch stacked on `sdk-blob-element-reading` (it reuses the
`decrypt_with_owner_key` helper introduced there). Adds
`MailFacade::load_mail_details_draft` plus the supporting
`CryptoEntityClient::load_encrypted` accessor. Cherry-picked into
`tutabridge-integration` so the bridge can finally render a body for
draft mails instead of logging "No details for mail …" on every
prefetch sweep.

Held from upstream submission; the rebase notes in SDK_PRS.md spell out
the dependency on the blob branch so the integration can be rebuilt
deterministically.
2026-05-28 15:49:40 +02:00
Anthony 9148787497 Drop the last two timers: prefetch and the UI stats poll go event-driven
Phase 2.5 — `prefetch_loop` no longer wakes every 30s. It subscribes to
`MailStore::subscribe()` and only catches up missing bodies when the
generation counter ticks (event-bus delta, sync_folder finishing,
bootstrap). When the store is quiet — every cached mail has its .eml on
disk — the loop sleeps indefinitely on the watch channel. The
`PREFETCH_INTERVAL` constant is gone.

Phase 2.6 — the dashboard 1s `setInterval` is gone too. `BridgeHandle`
gets a `stats_dirty_tx: broadcast::Sender<()>` that pulses on every
`MailStore` bump, every `WsState` transition, and the start / stop
status transitions. A small watcher task inside `start()` plumbs the
two `watch::Receiver`s into the broadcast. The Tauri layer
(`stream_stats`) subscribes via `BridgeHandle::subscribe_stats()`,
takes a stats + status snapshot on every pulse (and once at startup),
and emits two events `bridge://stats` / `bridge://status` to the
webview. The React hook replaces `setInterval(refresh, 1000)` with two
`listen()` subscriptions; the initial `refresh()` still seeds the
state for the first frame.

Backend now has zero `time::sleep`-driven polling loops: the syncer is
driven by Phase 0 + bootstrap + the event bus, prefetch is driven by
store changes, and the UI is driven by pushes. The only remaining
delays are throttling (`INTER_FOLDER_DELAY`, `INTER_REQUEST_DELAY`)
and reconnect backoff, which are not polls.

166 bridge lib tests, full workspace builds, UI tsc clean.
2026-05-28 15:19:52 +02:00
Anthony ffc1d7c9d0 Phase 3c: Mail CREATE inline → zero REST on a fresh mail, body included
A Mail CREATE event carries the encrypted Mail in `event.instance` and
its MailDetailsBlob in `event.blob_instance`. The bridge now pre-decrypts
both at the start of each batch into a `pending: HashMap<eid, PendingMail>`
pool, then the matching `MailSetEntry CREATE` consumes the entry by
element id — no `load_mail` REST call, and when the blob was present the
RFC 2822 `.eml` is rendered + written + `has_details = 1` on the spot,
so the prefetch loop never has to fetch the body either.

Total: a brand-new mail arriving over the event bus now needs 0 REST
calls when the server bundles the inline payloads (the common case).
Old path required 2 (load_mail + load_mail_details_blob).

New MailBackend method `decrypt_inline_mail_details_blob` delegates to
the SDK's `CryptoEntityClient::decrypt_inline_and_parse::<MailDetailsBlob>`
and extracts the `details` aggregate so the caller stays in terms of
`MailDetails`. The bucketer now routes Mail CREATEs to their own
`mail_creates` bucket so the pre-decrypt step runs ahead of the
MailSetEntry CREATE consumers. Existing CREATE-on-Mail behaviour
(nothing happens directly, MailSetEntry CREATE drives placement) is
preserved.

Fallback chain unchanged: a missing payload, an unresolvable session
key, or a decrypt error leaves the pool entry absent and the
MailSetEntry handler falls back to its existing inline-MailSetEntry →
`load_mail` ladder. 166 bridge lib tests pass (1 new bucket test
covers the routing).
2026-05-28 15:13:04 +02:00
Anthony 922738fd1e Wire inline decrypt into the event handler — Mail UPDATEs go REST-free
Two new MailBackend methods, `decrypt_inline_mail` and
`decrypt_inline_mail_set_entry`, delegate to the SDK's
`CryptoEntityClient::decrypt_inline_and_parse<T>`. The event handler now
takes the inline path first, and only falls back to `load_mail` if the
payload was missing or its session key was unresolvable:

- Mail UPDATE: a new `resolve_mail` helper centralises the
  decrypt-then-fallback policy. Most UPDATE events (read/unread, label
  moves, …) now do zero REST calls, since the encrypted Mail rides
  inside `event.instance`.
- MailSetEntry CREATE miss path: decode `event.instance` of the
  MailSetEntry inline, read its `mail: IdTupleGenerated` field, then
  `load_mail` it with the correct list_id. Drops the previous
  `mail_list_id_cache` sniffing hack on MailStore (and its test) —
  the inline payload always carries the correct list_id, no need to
  guess from any cached Mail.

`resolve_mail_set_entry` factors the same try-inline-first pattern for
MailSetEntry events. Both helpers fall back to a `sync_folder` if every
path fails, preserving the no-silent-miss guarantee from Phase 2.

`MockBackend` returns `Ok(None)` from both new methods by default so
existing handler tests keep their REST behaviour; the live SDK fixture
test (`tests/decrypt_inline_test.rs` on the SDK side) covers the real
decryption.

165 bridge lib tests pass.
2026-05-28 15:05:54 +02:00
Anthony 0bd8dc4110 Track sdk-inline-decrypt in the SDK integration
New SDK branch off upstream/master that adds
`CryptoEntityClient::decrypt_inline_and_parse<T>` plus the supporting
`EntityClient::parse_raw` accessor. Cherry-picked into
`tutabridge-integration` so the bridge can skip the `load_mail` REST
call on every Mail UPDATE / new-mail arrival — the encrypted payload
already rides along inside the event-bus `EntityUpdate.instance`.

Held from upstream submission until a working bridge consumer ships.
2026-05-28 15:00:44 +02:00
Anthony d31bd49165 Log the realtime delta path so it can be observed in the field
The new MailSetEntry CREATE/DELETE path applies changes silently to
MailStore + LocalStore; debug logs make it visible in the dev log so
we (and users running with RUST_LOG=debug) can confirm which path was
taken on a given event:

- `Event bus: cloning mail X from <source> → <target> (no REST)` —
  hit path, the mail was already cached in another folder.
- `Event bus: targeted load_mail(...) → <target> (1 REST call)` —
  miss path, the mail had never been seen.
- `Event bus: removed mail X from <source> (no REST)` — DELETE
  applied without re-listing.

Live-tested: a TB-initiated MOVE between two cached folders now
logs the cloning + removed lines and zero `Pre-fetching` / `Removed N
deleted` lines for the affected folders, confirming we no longer touch
the REST API for that case.
2026-05-28 14:41:11 +02:00
Anthony 34127f7587 Event-driven realtime delta: skip the full folder re-sync on a MOVE
The bridge no longer asks the server for a full folder listing on every
MailSetEntry CREATE/DELETE event. Instead it uses the encoding of the
entry id (4-byte timestamp + 9-byte Mail element id, see
`tuta-sdk::mail_set_entry_id`) to recover the affected mail directly,
and applies the delta to MailStore + LocalStore:

- MailSetEntry CREATE first (so a MOVE clones from the source folder
  before the matching DELETE runs). Hit path = `find_mail_anywhere` →
  clone the already-decrypted StoredMail into the target folder with a
  fresh UID. Miss path = a single `load_mail` against the cached
  `Mail.list_id`. Any decode failure / unknown folder / `load_mail`
  error queues a fallback full `sync_folder` for that folder — no
  silent miss.
- MailSetEntry DELETE second. Removes from the source folder only; the
  `.eml` and DB row are dropped only if no folder still holds the mail
  (multi-folder placement preserved, in-batch MOVE is correct because
  the target was upserted by the CREATE loop).
- Mail UPDATE / DELETE unchanged.
- MailSet folder-list dirty handling unchanged.

Saves the `load_range(1000)` round-trip on the common MOVE case. The
fallback path keeps the previous behaviour available so the change is
strictly an optimisation, not a behavioural change.

New MailStore helpers (5 new tests):
- `find_mail_anywhere(eid)` / `is_mail_anywhere(eid)` — multi-folder
  lookup.
- `remove_mail_from_folder(folder_id, eid)` — scoped remove (vs the
  existing `remove_mail_everywhere`).
- `upsert_mail_in_folder` — idempotent insert/replace by element_id.
- `mail_list_id()` — sniff once, cache; needed by the load_mail miss
  path.

`mail_to_metadata` made `pub(crate)` for the handler.

Bucketing in the event handler also split into `mail_set_entry_creates`
vs `mail_set_entry_deletes` so the order is explicit; 8 handler tests
cover empty / mixed / order-preserved / immutable-UPDATE-ignored shapes.

166/166 bridge lib tests pass.
2026-05-28 14:32:36 +02:00
Anthony d0457b24f6 Track sdk-mail-set-entry-id in the SDK integration
New SDK branch off upstream/master that adds the
`mail_set_entry_id::{construct, deconstruct}` helpers. Cherry-picked into
`tutabridge-integration` so the bridge can decode `MailSetEntry` ids
straight from event-bus payloads in the upcoming delta-apply path —
no REST round-trip when a mail moves between two cached folders.

Held from upstream submission until a working bridge consumer ships.
2026-05-28 14:26:30 +02:00
Anthony eb3239e84d IMAP push: notify on set changes, not just count changes
`check_new_mail` only emitted `* N EXISTS` when the folder count changed.
That misses a real-world case: a mail moved between two folders that
share the same `sync_limit` window keeps the count constant (one mail
in, one mail out) while swapping the set — so the IDLE'd Thunderbird
session is never told and silently sticks to a stale view.

Diff by element id instead. For every mail in `self.mails` whose id is
no longer in the refreshed store, push an `* N EXPUNGE` (in descending
seqno per RFC 3501 so subsequent values don't shift). Push one `EXISTS`
afterwards with the new count; the client refetches and discovers any
freshly added mail. Same-set updates stay quiet.

5 new tests cover the four shapes (no-op, growth, removal, swap with
unchanged count) and the descending-EXPUNGE ordering.
2026-05-28 14:01:34 +02:00
Anthony e6a2901b1d Doc the sdk-event-bus amendments (WsState + 26 tests, live-tested) 2026-05-28 13:43:34 +02:00
Anthony c9ea7c655f Surface the event-bus WebSocket state in the UI
SDK (sdk-event-bus amended on the fork): a new `WsState` enum
(`Stopped`/`Connecting`/`Connected`/`Reconnecting`) is broadcast through a
`watch::Sender` inside `EventBusClient`, exposed via `state()` for
observers. Transitions are emitted at every step of the reconnect loop,
plus a `Drop` guard guarantees a final `Stopped` even on a panic in the
caller's task tree. Two new SDK tests cover the initial value and
multi-subscriber broadcast.

Bridge: capture `bus_client.state()` at start, mirror it into a serde
`WsStatus` field on `BridgeStats`, and clear on stop.

UI: add a third stat card "Realtime" with a colored dot (green Connected,
orange pulsing Connecting/Reconnecting, gray Off) reading
`stats.ws_status`. Stats grid switches from 2 to 3 columns.

Submodule pointer bumped to the rebuilt `tutabridge-integration` which
cherry-picks all six SDK branches on `upstream/master`; the move-mails
fixup (use `make_test_facade` so its test compiles alongside the blob
branch) is re-applied. 341/341 SDK lib tests pass, 154/154 bridge tests
pass.
2026-05-28 13:43:05 +02:00
Anthony cfb030272d Polish realtime: folder CRUD, out-of-sync fallback, model versions auto
Three Phase-3 polish items, bridge-only:

1. Folder CRUD events. A MailSet (typeId 429) event in a batch now flips
   the `Bucketed.folder_list_dirty` flag; the handler refreshes the
   folder list and prunes any folder that disappeared from the server
   (both in-memory and from LocalStore + .eml files). New helpers
   `MailStore::prune_unknown_folders` and `LocalStore::delete_folder_mails`.

2. Out-of-sync detection. The server only replays missed batches for
   ~44 days. At startup we now check the oldest `event_bus_state` row;
   if it predates that window we wipe the table so the syncer falls
   through to a bootstrap full sync instead of looping on a server
   refusal. New helpers `event_bus_state_min_updated_at_ms` and
   `clear_event_bus_state`.

3. Model versions. Drop the hard-coded `SYS_MODEL_VERSION = 150` /
   `TUTANOTA_MODEL_VERSION = 108` and read them at compile time from the
   vendored SDK's `type_models/{sys,tutanota}.json` via `include_str!` +
   `LazyLock`. They now track every SDK submodule bump automatically.

154/154 lib tests pass (6 new across bucket_marks_folder_list_dirty,
prune_unknown_folders, delete_folder_mails, event_bus_state min+clear,
parse_model_version + sanity check on the included JSON).
2026-05-28 12:56:34 +02:00
Anthony 96b0cf631a Extract a pure bucket_updates and unit-test the new mail-side code
Pull the event-routing decision out of event_handler::apply_batch into a
pure bucket_updates(&[EntityUpdateEvent]) -> Bucketed function so it can
be tested without standing up a MailStore or hitting the network. Six
new tests cover empty batches, foreign apps, unknown type ids, the
MailSetEntry-list de-duplication, mail-event ordering and a mixed batch.

Add four MailStore tests for the new helpers: refresh_mail_in_place
must update the metadata in every folder that holds the mail (Tuta's
model allows multi-folder placement) while preserving the per-folder
UID, and must no-op on an unknown id; remove_mail_everywhere drops from
all folders and no-ops on an unknown id.

Drop the dead `let _ = bus_event_groups;` in bridge.rs and document why
event_groups() is not passed to the bus: the WebSocket subscribes
implicitly via the auth, and the URL's `groupsToLastEventBatchIds=` is
purely a per-group catch-up cursor.

148/148 lib tests pass.
2026-05-28 12:42:03 +02:00
Anthony 8a5683ef04 Paginate load_mail_ids_for_folder above the server's 1000-per-page cap
Replace the silent `min(limit, 1000)` cap with proper pagination: the
server rejects a single `load_range` count > 1000, so for any user-facing
`sync_limit` above that we now loop 1000-entry pages, advancing the
cursor with the last (oldest in DESC) entry's element id, until we have
the requested count or the list is exhausted. `limit == 0` still
delegates to `load_all`, which already paginates the whole list.

Honors what the user typed (1050 means 1050, not 1000).
2026-05-28 12:35:32 +02:00
Anthony 14bd40b87b Cap load_range at the server-side max (1000) per request
The Tuta entity REST endpoint rejects `count` > 1000 with a `Bad request`
400. A `sync_limit` above that (e.g. 1050 in config) was silently turning
every bootstrap and folder-re-sync into a 3-attempt retry loop that
always failed. Cap the per-request count and let any excess be picked up
by the realtime event bus going forward; multi-page stitching can come
back if a higher initial snapshot is ever needed.
2026-05-28 12:31:11 +02:00
Anthony 1b9a517b43 Realtime sync via the SDK event bus, drop the periodic poll
The 60s list-sync loop is replaced by the WebSocket event bus from the
SDK (sdk-event-bus). On startup the syncer still does Phase 0 (load the
local store into memory), then a one-shot bootstrap sync only if no
event-bus catch-up state is cached. From there on:

- `EventBusClient` runs in its own task, streams `EventBusMessage`s into
  an mpsc channel and reconnects with backoff.
- `event_handler` consumes the channel: MailSetEntry CREATE/DELETE
  triggers a targeted `sync_folder` for the affected folder; Mail UPDATE
  refreshes metadata in place; Mail DELETE drops the cache + .eml.
- After each batch the `(group_id, batch_id)` is persisted in the new
  `event_bus_state` SQLite table (schema bumped to v4) and mirrored in
  the bus's in-memory map, so the next reconnect resumes catch-up via
  `groupsToLastEventBatchIds`.

`stop()` aborts and awaits the new bus + handler tasks alongside the
existing syncer/IMAP/SMTP teardown, so ports release before the next
start rebinds them.

138/138 bridge unit tests pass (incl. 2 new ones for the event-bus state
table). End-to-end behaviour to be verified against the live server.
2026-05-28 12:02:18 +02:00
Anthony 57e594c7f8 Add sdk-event-bus to the SDK integration
Phase 1 of the realtime work: the SDK now has a WebSocket EventBus client
(branch sdk-event-bus, single commit off upstream/master, 24 unit tests). No
bridge code consumes it yet — Phase 2 will replace list_sync_loop with an
event-driven handler and persist last batch ids per group.

Held from upstream submission until a working bridge integration validates
the API surface.
2026-05-28 11:44:49 +02:00
Anthony 319c97b6bf Make the window a fixed shell with no scrollbars
Lock html/body/#root to the viewport and hide scrollbars everywhere; the
content area fills its space instead of scrolling the page, and the logs
stream scrolls within its own pane. Add a min window size so the layout
can't be shrunk below where everything fits.
2026-05-27 17:50:27 +02:00
Anthony MandGitHub f0656b595b Add sync-limit config UI + Restart button, fix restart teardown
- Expose the sync limit in the config panel: "Fetch all mail" checkbox (sync_limit=0) + a max-per-folder number input.
- Config fields are editable while the bridge runs; Save persists anytime, a Restart button (stop+start) applies changes.
- Fix the in-process restart: stop() was fire-and-forget and left IMAP/SMTP tasks holding their ports, so the next start failed to bind and aborted the new syncer before it listed folders (empty store). stop() now aborts all three tasks and awaits full teardown.
2026-05-27 17:31:03 +02:00
Anthony bc03d244c6 Fetch the whole folder when sync_limit is 0 ("all")
Previously sync_limit=0 still loaded a single 1000-entry page, so the
mailbox was effectively capped. Use the SDK's paginated load_all to walk
the entire MailSetEntry list when the limit is 0, keeping a single capped
load_range for a finite limit.

Live-tested: with sync_limit=0, INBOX loads all 19288 mails (was capped
at 500) with stable UIDs 1..19288.
2026-05-27 16:53:00 +02:00
Anthony 578a8357b6 Persist stable IMAP UIDs across restarts
UIDs were assigned from an in-memory counter that reset to 1 on every
bridge restart, so the UID<->mail mapping changed each run and IMAP
clients (Thunderbird) re-downloaded the whole mailbox on reconnect.

Persist a per-folder monotonic UID in the local store (schema v3):
- mails gain a `uid` column; sync_state gains a `next_uid` counter that
  only ever advances (UIDs are never reused).
- The syncer keeps each mail's existing UID and allocates new ones for
  new mail (oldest-first, so newer mail gets higher UIDs).
- refresh_mails uses the persisted UID instead of allocating; messages
  are ordered by UID. UIDVALIDITY stays constant.

Migration v2->v3 drops the cache tables and re-syncs once (encrypted
.eml files survive). Live-tested: UID<->mail mapping is identical before
and after a restart (range 1..500 unchanged), so clients fetch only the
delta instead of re-downloading.
2026-05-27 16:43:44 +02:00
Anthony 1630e3d860 Support IMAP MOVE between folders
Implement MOVE / UID MOVE (RFC 6851): resolve the target by IMAP path
(UTF-7 decoded), call the SDK move_mails for the selected messages, then
expunge them from the source view. Advertise the MOVE capability.

COPY is rejected with NO — Tuta folders are exclusive, so duplication
isn't supported; clients use MOVE instead.

FolderInfo gains the folder's list id so the target MailSet IdTuple can
be reconstructed. Pulls in the SDK move_mails (tuta-repo submodule bump).

Live-tested: a mail moved from one custom folder to a nested UTF-7 folder
lands in the target and leaves the source server-side.
2026-05-27 16:05:11 +02:00
Anthony d3b0f4a077 Decouple folder/list sync from body prefetch
The syncer ran one loop: phase 1 (folder list + mail-id lists) then
phase 2 (body prefetch). On a large mailbox the cold prefetch pass takes
many minutes, so the next folder refresh was stuck behind it and new
folders/mail only showed up after a restart.

Split into two independent loops sharing the store: a fast list_sync_loop
(folder list + mail ids, ~every SYNC_INTERVAL) and a slow prefetch_loop
(bodies, background). Folder and new-mail refresh no longer wait on
prefetch.

Live-tested: a folder created while the bridge runs appears over IMAP in
~18s, no restart.
2026-05-27 16:05:11 +02:00
Anthony 8f2bc4197d Encode folder names as modified UTF-7 over IMAP
Add an imap::utf7 module (RFC 3501 §5.1.3) and apply it at the protocol
boundary: encode mailbox names in LIST, decode them in SELECT/STATUS.
Folder names internally stay UTF-8; only the IMAP wire form is UTF-7.

Also fix STATUS argument parsing to handle quoted mailbox names with
spaces (it split on the first space, truncating names like
"Not Important" or nested paths).

Live-tested: "Café" lists as "Caf&AOk-", a nested child lists as
"Caf&AOk-/Test dossier avec espace", and SELECT/STATUS resolve both.
2026-05-27 16:05:11 +02:00
Anthony f37ff8bca6 Support custom folders over IMAP
Key the syncer, local store and IMAP server by Tuta MailSet folder id
instead of the system folder kind, so custom (and nested) folders are
first-class.

- tuta.rs: add FolderInfo; MailBackend.list_folders enumerates system +
  custom folders via the SDK FolderSystem tree, building IMAP paths and
  RFC 6154 special-use flags; load mails by the folder's entries list.
- store.rs: folder_kind INTEGER -> folder_id TEXT, schema v2 with a
  migration that drops the cache tables and re-syncs (encrypted .eml
  files survive).
- sync.rs: MailStore keyed by folder id; the syncer enumerates the live
  folder list each cycle.
- imap/session.rs: dynamic LIST/SELECT/STATUS driven by the folder list;
  drop the hardcoded six-folder mapping.

Pulls in the SDK FolderSystem tree (tuta-repo submodule bump).

Live-tested: 9 custom folders listed and selectable over IMAP, with
headers and decrypted bodies. Not yet covered: nested-folder paths,
modified UTF-7 for non-ASCII names, labels, and IMAP MOVE/COPY.
2026-05-27 16:05:11 +02:00
Anthony MandGitHub e58ee86bed Restructure into Cargo workspace with Tauri desktop GUI (#1)
Split the bridge into a tutabridge-core crate, a Tauri v2 desktop app
(src-tauri) and a React/TS UI (ui), keeping the CLI entrypoint at the
workspace root.

Add encrypted local storage (SQLCipher metadata index + encrypted .eml
files) so mail persists across launches and only the delta is fetched.

Wire the bridge to the Tuta Rust SDK via the tuta-repo submodule
(batch loading, MailDetailsBlob reading, interactive 2FA login).

Implement SMTP sending: build the draft and send it through Tuta's
DraftService/SendDraftService, mirroring the web client (body in
compressedBodyText, non-empty sender/recipient names, populated
SendDraftParameters). Add unit tests for the draft/send payload building.
2026-05-27 14:03:15 +02:00
Anthony 128772bb21 Add tuta-sdk as submodule, use SDK blob reading API
- Add tuta-repo as git submodule pointing to spartanz51/tutanota
  branch feat/rust-sdk-blob-read (pending upstream PR)
- Remove inline load_mail_details_blob hack, call through
  mail_facade().load_mail_details_blob() instead
- Remove /tuta-repo from .gitignore since it's now a submodule
2026-05-21 13:11:53 +02:00
Anthony 8595aedfad Initial commit: TutaBridge IMAP/SMTP bridge for Tuta
Local bridge that exposes Tuta encrypted email via standard
IMAP/SMTP protocols for use with Thunderbird and other clients.

Features:
- IMAP server with TLS (STARTTLS self-signed cert)
- SMTP server for sending mail via Tuta
- Session persistence via macOS Keychain
- Mail body decryption including LZ4-compressed blobs
- Blob storage access (BlobAccessTokenService + blob server)
- RFC 2822 message formatting
- Interactive first-run configuration
2026-05-21 11:46:32 +02:00