Route every SDK request through one GovernedRestClient (installed via
Sdk::new_without_suspension, replacing the SDK's header-only suspension
layer). It enforces:
- Bounded concurrency: at most MAX_IN_FLIGHT (4) requests in flight, so
attachment sub-loops, on-demand IMAP fetches across connections, and the
syncer can no longer stampede the API in parallel.
- Throttle backoff: on HTTP 429/503 it suspends all outbound traffic,
honoring the server's retry-after / suspension-time header, or an
escalating default backoff (2s to 60s) when the server gives no hint
(the gap the SDK left, which let the bridge keep hammering).
Because a 429 still returns to the caller while the gate is armed, the
existing retry layers become self-correcting: their next attempt blocks on
the gate instead of amplifying the flood.
7 unit tests: concurrency cap, throttle classification, backoff
honor/clamp/escalate/reset, suspension gate timing.
A mail subject or sender name containing a raw newline (or 8-bit bytes)
was emitted inside a quoted string, which IMAP forbids. The resulting
malformed FETCH response broke the client's parse of the message list,
leaving the mailbox empty in Thunderbird.
Add imap_string(): a quoted string for safe 7-bit text, a server-side
literal ({N}CRLF<octets>) when the value holds CR, LF or 8-bit bytes.
Applied to subject, message-id and envelope address fields.
Adds 6 unit tests (imap_string + a newline-subject regression); the old
imap_quote tests are migrated. Full lib suite green (259 tests).
Tuta asked (discussion #9960) to stop using their logo so the project is
not mistaken for an official one. Swap it everywhere for a neutral bridge
icon.
- App header and favicon now use the bridge logo
- Regenerated the full Tauri desktop icon set (sizes, .icns, .ico)
- Window title set to TutaBridge
- Removed tuta-logo.svg and the default Vite favicon
- Added logo.png master at the repo root
Mail clients save a copy of each sent message to the Sent folder with an
IMAP APPEND. The bridge did not implement APPEND, so Thunderbird reported
"a copy was not placed in your Sent folder" after every send.
Tuta saves sent mail server-side and the syncer brings that copy back, so
an APPEND to Sent is a no-op: read and discard the literal, reply OK,
which avoids creating a duplicate. APPEND to any other folder is rejected
before the literal is sent (the client then aborts the synchronizing
literal and the stream stays in sync); real APPEND-to-Drafts is left for
a follow-up.
The literal is read at the socket level since the session layer is line
based. Tested: parse_append, the Sent-folder decision, and the full
handle_append flow over an in-memory pipe (Sent reads the literal and
returns OK, a non-Sent folder is rejected with no continuation).
* mail: do not split a quoted display name on its comma
parse_address_list tracked angle-bracket depth but not quotes, so a
recipient like `"Doe, John" <john@x.com>` was split on the comma inside
the quoted name, yielding a bogus recipient (`"Doe`) next to the real
one. With a real contact named "Last, First" that either gets the whole
send rejected by Tuta or delivers to a garbage address.
Track the quote state too: inside `"..."`, commas and angle brackets are
literal. Tested with a quoted-comma recipient and a plain comma list.
* mail: decode non-UTF-8 bodies instead of echoing base64 or QP source
When a base64 or quoted-printable body decoded to bytes that were not
valid UTF-8 (e.g. a Latin-1 message), the parser fell back to returning
the still-encoded source: the recipient saw a wall of base64, or raw
=XX sequences. Decode the bytes lossily instead, so the text is readable
(non-UTF-8 bytes become the replacement char rather than garbage).
Full charset-aware decoding (Content-Type charset via encoding_rs) is a
follow-up; this fixes the worst symptom with no new dependency. Tested
with a non-UTF-8 base64 body and a non-UTF-8 quoted-printable byte.
* smtp: enforce message size and line length limits
The server advertised SIZE 26214400 in EHLO but never enforced it, and
the DATA loop appended every line into an in-memory buffer with no cap,
so a single local client could grow the process memory without bound (a
line with no terminator was read unboundedly too).
Enforce both: reject a MAIL FROM that declares an over-limit SIZE, stop
buffering and reply 552 once a message exceeds the cap, and bound each
protocol line. handle_connection is now generic over the stream so the
whole conversation can be exercised over an in-memory pipe; tests cover
the size param, the DATA cap, the per-line cap, and a normal send.
* backup: run mail decryption and writes off the async runtime
export_eml decrypted each cached .eml.enc and wrote the output file
inline on the async task. A GUI backup reuses the running bridge's
runtime, so over a large already-synced mailbox that tight, non-yielding
loop pinned a worker and froze the live IMAP/SMTP servers for the whole
export (the same failure class as the cached-folder load).
Wrap the per-mail decrypt and file write in block_in_place so the worker
hands its other tasks off and the servers stay responsive. The backup
integration tests run on a multi-thread runtime now (block_in_place
requires it) and still assert the same cache/server/resume behaviour.
* net: tolerant accept loop with a connection cap and handshake timeout
Both servers ran `loop { listener.accept().await? }`. A single transient
accept error (EMFILE, ECONNABORTED, ...) propagated out and stopped the
server for good, nothing bounded concurrent connections, and a stalled
TLS handshake was never timed out (a client that connects but never
negotiates parked a task and a file descriptor forever).
Extract a shared net::accept_loop that logs and retries a failed accept,
caps concurrency with a semaphore (64 connections), and wrap each
handshake in a 15s timeout. The loop is transport agnostic so it is unit
tested without TLS: one test proves it keeps accepting across
connections, another that it bounds concurrency at the cap.
* event-bus: recover poisoned last_batch_ids lock instead of panicking
last_batch_ids is a std Mutex shared between the bridge, the event
handler, and the SDK's reconnect path. Every accessor used
.lock().unwrap(), so one panic while holding it would poison the mutex
and make every later lock (the SDK reconnect included) panic, killing
realtime sync for the rest of the process's life.
Add util::lock_recover (locks, recovering the guard from poisoning) and
use it at the bridge-side accessors. Tested against a poisoned mutex.
Loading a cached folder, and the one-time full-text backfill, both
decrypt every cached .eml.enc body (AES-CBC plus an HMAC-SHA256
verification) in a tight loop with no await points. Run inline on a
tokio worker, that loop keeps the worker and the IO driver it holds busy
for the whole duration, so the IMAP and SMTP accept loops stop being
polled. On a large mailbox, connecting a client or sending a message
times out for the first 10 to 90 seconds after launch while the cache
loads, even though most cores sit idle.
Profiling during the stall showed 15 of 16 workers parked, 1 grinding
through SHA-256 and AES, and nothing polling kqueue.
Move the per mail decode (metadata deserialize, body read and decrypt)
onto the blocking pool via spawn_blocking, for both the startup cache
load and the FTS backfill. The worker threads stay free to drive IO, so
IMAP and SMTP answer immediately while the mailbox loads in the
background.
Onboarding now works on a fresh install with no saved session. The login
does a single initiate_session like the CLI: the two factor callback fires
only when the account actually needs a code, emits bridge://need-totp so the
dashboard reveals the code field, and blocks until submit_totp delivers it.
One auth either way, so it no longer trips Tuta's rate limit the way the old
two step flow did.
First run also gets an email field (the start command bootstraps a config
from the address entered on the dashboard instead of erroring out).
Fixes the dashboard showing zero mails and frozen uptime: stats were purely
event driven, so once the store went quiet after the initial sync no further
snapshot was pushed and uptime stopped climbing. stream_stats now also emits
on a one second tick, which advances uptime and recovers any pulse the UI
missed while the start lock was held through the 2FA wait.
The GUI login path passed no TOTP callback, so a fresh sign-in on a 2FA
account failed with "2FA required but no TOTP callback provided". The
dashboard login form now has an optional two-factor code field next to the
password, and start_bridge forwards it as the TOTP callback, so a 2FA
account signs in on a single attempt. If 2FA is needed but no code was
entered, the form surfaces a hint instead of a raw error.
The dashboard only ever showed a password field, and start_bridge errored
with "No config found" when nothing was configured yet, so a brand-new user
could never get past the start screen. Now the dashboard shows a Tuta email
field too when no account is set up, and start_bridge bootstraps the config
from that email (with defaults) instead of failing. Once an account exists
the email field disappears and only the password is asked (until a keyring
session is saved).
Add a prebuilt tutabridge-bin package (downloads the published x86_64 CLI
binary, no Rust build) alongside the build-from-source tutabridge-git, each
in its own directory with a PKGBUILD and .SRCINFO. Update the maintainer
address and the packaging README for the two-package layout.
Connection: incoming and outgoing servers sit side by side so the panel fits
the fixed window without scrolling.
Dashboard: replace the oversized "Bridge is running" hero with a compact
status bar (a state LED plus a one-line status and the stop button). The LED
is green only when realtime is actually connected and orange while it
reconnects, and the subtitle stays empty when everything is healthy so it
never repeats what the stat cards already show. Realtime no longer has its
own card. The Logs tab is gone: the activity log now lives at the bottom of
the dashboard, filling the leftover space and scrolling inside itself.
tauri-action names the installers with the version, which would break any
fixed download URL on the next release. Add a workflow step that uploads
version-less aliases (TutaBridge-macOS.dmg, TutaBridge-Windows-setup.exe,
TutaBridge-Linux.AppImage/.deb/.rpm) next to them, and point the README
download links at releases/latest/download of those stable names so they
follow every future release automatically. The CLI assets were already
version-less. The current rc.1 release was backfilled with the aliases.
Add a prominent "please read before using" block up top: it states plainly
that the bridge works against Tuta's end-to-end model and widens the attack
surface, links Tuta's own public stance, and frames who it is actually for
(advanced users who trust their device but not the provider). Simplify
Download (per-OS links to the latest release) and Getting started (three
steps plus a connection table). Remove every dash separator from the prose.
The MCP section made the Config tab overflow the fixed window. Split it into
Account / Sync / AI access sub-tabs with a scrollable body and a pinned Save
bar, so each section stays short and Save is always visible. Also fix the
select sitting flush against its help text, and reword the hints without dash
separators.
Expose the mailbox to an LLM client (Claude Desktop / Code) over an
in-process MCP server, so the bridge itself hosts it and the GUI controls
it live. Strictly read-only: there is no tool that sends, moves, deletes or
mutates mail — by design and asserted in tests.
Transport: Streamable HTTP (MCP 2025-06-18) on a single POST /mcp endpoint
bound to 127.0.0.1, answering each JSON-RPC request with application/json
(no SSE — the server never pushes). Auth is a bearer token (the bridge
password); the Origin header is validated to block DNS-rebinding.
Permission tiers (config.McpPermission, default Disabled = server off):
- Metadata — folders, metadata search (subject/sender/date), headers only.
- Full — the above plus full-text body search and message body text.
Tools: list_folders, search_messages, list_unread, get_message. Search
combines subject/sender always and the encrypted FTS body index under Full;
get_message returns headers always and body only under Full.
Wiring: spawned in-process by both the CLI (main.rs) and the GUI bridge
task (bridge.rs); a Disabled tier makes serve() a no-op, and it is kept out
of the select! so it never triggers teardown. GUI gains an MCP section
(tier selector, port, full-read warning, "copy client config" button) and a
get_mcp_client_config command that emits the ready-to-paste client snippet.
Validated live on a ~19k-message mailbox: initialize / tools/list /
tools/call all conform; 401 without the bearer token, 403 on a foreign
Origin, 202 on notifications; list_folders, body search and get_message
(HTML stripped to text) all return correctly. 240 unit tests.
Add a download-and-run path for non-developers: per-OS install from the
Releases page (desktop app vs CLI binary, with the Gatekeeper / SmartScreen
one-time override each needs), a step-by-step getting-started with the
IMAP/SMTP connection table and Thunderbird / Apple Mail notes, and an
unofficial-&-unsigned disclaimer up top. Reframe the old cargo-centric
sections as 'Build from source', and document that body search covers
downloaded messages while metadata search covers the whole mailbox.
BODY/TEXT searches previously matched only bodies that happened to be
decoded in memory, so results were inconsistent. Add a persistent FTS5
index (a virtual table inside the SQLCipher store, encrypted at rest) over
the plain-text body of every message we download.
- store.rs: mail_fts(element_id UNINDEXED, body) with unicode61 +
remove_diacritics; index_body / unindex_body / search_body / fts_count.
Terms become prefix tokens ANDed together (factur -> factur*), built by
fts_match_expr which strips everything but alphanumerics so it is
injection-safe.
- rfc2822.rs: strip_html (drops tags + script/style + entities) and
extract_body_text (decodes the text part of our own .eml) feed the index.
- sync.rs: index inline at prefetch; one-time backfill at boot
(body_fts_indexed_v1) for bodies cached before the index existed;
unindex on delete.
- search.rs: BODY/TEXT resolve through the index — the session collects the
distinct body terms, queries the index once each, and passes the hit sets
to matches() via a SearchContext. A body term only matches messages whose
body has actually been downloaded (full coverage needs sync_limit = 0).
- LocalStore threaded into ImapSession (Option; None in unit tests).
Validated live: backfilled 7,687 cached bodies, then BODY/TEXT/AND/OR/NOT
queries returned coherent subsets — NOT BODY x == total - (BODY x), an
exact complement. 233 unit tests, incl. real FTS5 MATCH against the bundled
SQLCipher (confirms FTS5 is compiled in for the cross-OS release).
cmd_search only ever special-cased UNSEEN — every other query (SUBJECT,
FROM, SINCE, …) fell through to "return all message ids", so a search in
Thunderbird silently matched the entire mailbox. Now that the full mailbox
is listed, that made search actively misleading.
New imap/search.rs parses the RFC 3501 SEARCH grammar into a SearchKey
tree (AND/OR/NOT, parens, CHARSET prefix, quoted strings, sequence/UID
sets) and matches each message via a lightweight MsgView the session
projects from its cached mail. Coverage is metadata-first: SUBJECT, FROM,
TO, CC, BCC, HEADER, flags, dates (BEFORE/ON/SINCE + SENT*), LARGER/
SMALLER, UID and sequence sets. Flag predicates resolve consistently with
what we report over FETCH (only \Seen and \Deleted exist). BODY/TEXT match
the body only when it's already decoded — whole-mailbox full-text body
search is the next increment, backed by an on-disk index.
Unknown criteria degrade to a non-restrictive match so search never hides
a message.
Validated live on a 19,322-message INBOX: SEEN+UNSEEN partition the
mailbox exactly, NOT SEEN == UNSEEN, AND/OR compose, and a nonexistent
subject now returns 0 hits instead of everything. 223 unit tests.
The local store was capped at sync_limit, so IMAP only ever listed the
newest N messages — a search in Thunderbird (the only search UI we have)
silently missed everything older. Now the syncer lists the *full* mailbox
metadata for every folder, and sync_limit governs only how many recent
message bodies are pre-warmed offline. Bodies outside that window are
fetched on demand the first time a client opens the message.
A one-time full-metadata sync (marker full_metadata_synced_v1) completes
the mailbox view on first launch after upgrade.
Crucially, an empty body is now stored as rfc2822 = None rather than a
rendered "(No body available)" placeholder: the placeholder looked like a
real body to the IMAP layer and suppressed the on-demand fetch. CachedMail
gains body_loaded to track whether the body (not just the headers) is final.
Validated live on a 19,322-message INBOX (26,965 mails total across
folders): full listing, on-demand body fetch (~0.1-0.4s), in-memory cache
on re-fetch.
The bundled-sqlcipher feature relied on a system OpenSSL for SQLCipher's
crypto — fine on macOS/Linux, but absent on Windows, where the release
build failed at libsqlite3-sys. Switch to
bundled-sqlcipher-vendored-openssl: OpenSSL is built from source, so the
build is self-contained and identical across all three OSes (and the AUR
package needs no system OpenSSL). Caught by the multi-OS release dispatch.
`workflow_dispatch` builds the installers on all three OSes and uploads
them as workflow artifacts (no tag, no release) — so the pipeline can be
validated without burning a version tag. A real `v*` tag still produces
the draft release. GUI bundles are globbed from the per-OS bundle dir.
On a `v*` tag: builds GUI installers for macOS (universal .dmg),
Windows (.msi/.exe), and Linux (.deb/.AppImage) via tauri-action, plus
the headless CLI binary per OS, and attaches them to a draft GitHub
Release for review before publishing.
The sync Secret Service backend links system libdbus via libdbus-sys, so
the Linux CI job installs libdbus-1-dev + pkg-config and the PKGBUILD
declares dbus (build + runtime). Caught by the linux-cli CI job.
`tutabridge-git` VCS package builds only the headless CLI (no GUI/Node).
Handles the SDK submodule in prepare(), fetches crates for an offline
`--frozen` build, installs the binary + a systemd *user* unit (the
bridge runs per-user, binds localhost, uses the login keyring).
packaging/aur/README documents local build + AUR publish (.SRCINFO is
generated on Arch).
Add an ubuntu job that builds `-p tutabridge` and tests
`-p tutabridge-core` — proving the AUR/daemon target compiles without
the GUI's native deps. Installs cmake + nasm for aws-lc-sys.
The keyring dep was hard-pinned to the macOS `apple-native` feature, so
the headless CLI/core didn't compile anywhere else — blocking an AUR
package or any Linux/Windows use. Split it into per-target features:
apple-native (macOS), windows-native (Windows), and
sync-secret-service + crypto-rust (Linux, via gnome-keyring/KWallet,
pure-Rust crypto so no OpenSSL build dep).
Regenerate all desktop icon sizes (.icns / .ico / PNGs / Windows Store
logos) from the Tuta brand mark via `cargo tauri icon`, and reference
icon.icns + icon.ico in tauri.conf so bundled macOS/Windows builds use
them. Mobile (android/ios) icon sets are dropped — desktop-only app.
Use the Tuta brand mark (from the tutanota repo) as a small icon next
to the TutaBridge title. Trademark belongs to Tuta — used here only to
identify the service the bridge connects to.
GitHub Actions on push/PR (macOS runner): checks out the vendored SDK
submodule (full history so its pinned fork-branch commit is reachable),
builds the frontend (tauri-build needs ui/dist), then runs
`cargo fmt --check`, clippy (advisory for now), and
`cargo test --workspace`. Caches cargo to keep runs reasonable.
Also point .gitmodules at `tutabridge-integration` (the branch the
submodule commit actually lives on).
TutaBridge links Tuta's Rust SDK, which is part of the GPLv3-licensed
tutanota project, so the bridge must carry the same license. Add the
full GPLv3 text, set `license = "GPL-3.0-or-later"` on all three
crates, and note it in the README.
* Add complete mailbox backup to .eml files (CLI)
`tutabridge backup <dir>` exports every mail of every folder to a
plaintext `.eml` tree, mirroring the IMAP folder hierarchy.
A backup must be *complete*: it enumerates all mails per folder from
the server (`limit == 0`), not just the `sync_limit`-capped subset the
bridge keeps cached. Dumping only the synced subset would silently drop
mail — live-tested here against an INBOX with 6288 server-side mails vs
1050 cached, all 6288 exported. The encrypted local cache
(`.eml.enc`) is used as a fast path; only never-synced mails trigger a
rate-limited (150ms) server fetch.
Format: one `.eml` per mail in `<output>/<folder path>/<YYYYMMDD-HHMMSS>_<id>.eml`.
EML is the most portable target — native to Thunderbird/Apple Mail/
Outlook, no Maildir `:2,S` colons that break on Windows, and a single
corrupt file never takes down the whole archive. Folder path segments
are sanitised for cross-platform filesystems (Windows-illegal chars +
trailing dot/space stripped); the date prefix makes a directory listing
sort chronologically.
`backup::export_eml` is surface-agnostic (takes a progress callback) so
a GUI button can wrap the same engine later. Per-mail failures are
collected in `BackupStats::errors` rather than aborting the run. The CLI
shares the keychain/password login flow with the bridge via the new
`login_session` helper, and opens the cache without the bridge's
reset-on-key-mismatch (a backup must never destroy the cache).
8 backup unit/integration tests: filename + folder sanitisation,
date stamp, and an end-to-end export over a mock backend asserting the
cache-vs-server split, file tree layout, and verbatim cached bodies.
GUI button is a follow-up (needs the Tauri dialog plugin).
* Make backup resumable / incremental
Skip a mail when its `.eml` is already on disk, before any cache read
or server fetch. The filename is deterministic (stable receivedDate +
element id) and mail content is immutable, so an existing file is never
stale. This turns an interrupted backup into a resume (re-run continues
where it stopped) and a periodic re-backup into an incremental one
(only new mail is fetched — the expensive part). New
`BackupStats::skipped` counter, surfaced in the CLI summary.
Two tests: a re-run skips every already-exported mail (zero server
loads), and an incremental run fetches only the newly-arrived mail.
* Add Backup tab to the GUI
A "Backup" tab wraps the same `backup::export_eml` engine as the CLI:
a native folder picker (tauri-plugin-dialog), a live per-folder
progress bar driven by `bridge://backup-progress` events, and a result
summary (mails written, folders, MB, cache vs server vs skipped).
`BridgeHandle` now keeps the logged-in backend + local cache after
`start` and exposes them via `backend_and_store()`, so the
`export_mails` command reuses the live session instead of opening a
second one — and drops the handle lock before the (minutes-long)
export so status/stats stay responsive. The button is disabled unless
the bridge is running.
`BackupStats` is now `Serialize` so it can cross the Tauri boundary.
* Keep backup state across tab switches
The Backup tab is conditionally rendered, so switching away unmounted
`BackupPanel` mid-export — dropping its progress + result state and the
`bridge://backup-progress` listener while the Rust task kept running.
Coming back showed an idle panel even though the backup was still going.
Lift all backup state (busy / progress / result / error), the
`startBackup` action, and the progress listener into the always-mounted
`useBridge` hook. The listener is now active regardless of which tab is
shown, and `BackupPanel` is purely presentational — switch tabs freely
mid-backup and the progress is intact on return. `startBackup` guards
against a double launch while one is in flight.
The original implementation consumed the cache entry on first lookup,
which meant the prefetch sweep that ran first (typically the Sent
copy) got its multipart envelope rebuilt correctly, but the second
sweep (the Inbox copy, which is the one suffering from the
`File._ownerEncSessionKey` race) found an empty cache and fell back
to the failing `crypto_client.load` path — leaving its .eml body-only.
A self-send produces `load_attachments` calls for both folder copies
of the same envelope, so the cache must serve as many lookups as
arrive within the TTL. Clone the cached attachments out of the entry
instead of removing it; the existing TTL (1h) + soft cap
(50 entries) keep memory bounded. Also move the cache insert ahead of
`DraftService.post` so the WS event for the inbox copy cannot beat
the insert.
Live-verified: both Sent and Inbox copies of a self-sent mail with a
PDF attachment now expose a proper `multipart/mixed` BODYSTRUCTURE
with the file part — no more session-key-transient retry storms in the
prefetch logs.
Tuta's server never publishes `File._ownerEncSessionKey` on the
recipient-side copy of a mail the user sent to themselves over SMTP.
The TS client survives this because it caches each file's plaintext
session key locally at send time and re-uses it for the inbox copy
without going through `crypto_client.load::<TutanotaFile>()` — the
Rust SDK has no such cache, so the bridge's previous retry-on-WS-event
mechanism logged 'still not decryptable after retries' forever.
Mirror the TS behaviour in `TutaSession`:
* A small `HashMap<key, SelfSendCacheEntry>` keyed by
`subject + from + first_recipient` (lower-cased + trimmed) — those
three fields are preserved verbatim across the Sent and Inbox copies
of a self-send, so the inbox-side lookup always finds the entry the
send side just dropped in.
* `cache_self_send_attachments` runs from `send_mail_impl` only when
`is_self_recipient` is true (the recipient list contains the bridge's
own address). Third-party recipients hit Tuta's normal pipeline,
which populates File metadata before delivery, so caching there
would just waste memory.
* `try_self_send_cache` short-circuits `load_attachments_impl` for
the inbox copy, returning synthetic `TutanotaFile` records (only the
fields `mail_to_rfc2822` actually reads) alongside the plaintext
bytes. The entry is consumed on hit — the .eml the prefetch writes
next becomes the durable cache.
* TTL = 1h, soft cap 50 entries (LRU eviction on insert).
Seven unit tests pin the cache key normalisation and the self-send
detection (positive when To/Cc match From, negative when either
diverges, case-insensitive throughout).
The single in-line retry budget (2/4/8/16s, ~30s total) bumps the
session-key-transient race down but does not solve it: self-sends can
take longer than 30s to surface a usable File entity. Worse, the
in-line wait blocks the whole prefetch sweep on the offending mail.
Replace the strategy with an async pending state:
* New `attachments_pending: bool` on `StoredMail`. Set when a
prefetch_details pass sees `is_transient_attachment_error(e)`; the
body-only RFC 2822 is still written to disk + the store so IMAP keeps
serving the message immediately.
* Second pass in `prefetch_details` retries the attachment-only step
for any mail flagged pending, throttled by a per-mail
`HashMap<element_id, Instant>` (`ATTACHMENT_RETRY_THROTTLE = 60s`).
On success the cached .eml is rewritten as multipart, the flag is
cleared, and the throttle entry is forgotten. On permanent failure
the flag is also cleared so we stop hammering the server.
* `prefetch_loop` now wakes either on a store mutation **or** after
`ATTACHMENT_RETRY_THROTTLE` when any throttle entry exists, so the
retry happens even if no other mail traffic touches the store.
Bridge-side adds `MailStore::update_mail_rfc2822` (rewrite the body
without touching cached details), and threads the new field through
every `StoredMail` construction (tests included). SDK-side bumps the
in-line `load_file_with_retry` budget to 2/4/8/16s so most propagation
delays still resolve before we surface the transient error to the
pending path.