Synchronization architecture
This page is the developer reference for how MailDesk moves mail from a provider into Odoo and keeps the open UI fresh. It complements Realtime architecture: this page documents the shipped, stable 4.1.x pipeline (scheduled jobs + bus + the SSOT/cache/queue data model); the realtime page documents the 4.2.0 near-release push/prefetch layer that sits on top of this pipeline.
This page is the developer reference for how MailDesk moves mail from a provider into Odoo and keeps the open UI fresh. It complements Realtime architecture: this page documents the shipped, stable 4.1.x pipeline (scheduled jobs + bus + the SSOT/cache/queue data model); the realtime page documents the 4.2.0 near-release push/prefetch layer that sits on top of this pipeline.
Scope of "stable" here. Everything in §1–§5 is on
18.0HEAD inmaildesk_mail_client(Basic) andmaildesk_mail_client_pro(Pro). The 4.2.0 true-push and body-prefetch work is not yet merged — it lives on branchesMailDesk-889-realtime-sync-v18/MailDesk-prefetch-cache-v18, tracked by MRs !237 (Basic) and !124 (Pro). See Realtime architecture for that layer.
1. The shape of the pipeline
Provider (Gmail / Graph / IMAP)
│ scheduled sync (cron) ── §3 jobs 1–4
▼
maildesk.message_index ◄── SSOT, identity = unique(account_id, folder, uid) ── §2.1
│
├─► maildesk.ui_cache body/metadata TTL cache, advisory-locked ── §2.2
│
└─► maildesk.ingest_queue durable SSOT → Odoo mail.thread import ── §2.3
│ cron_scan_aliases (index → queue) + cron_import (queue → mail.thread)
▼
Odoo mail.thread / mail.message
Pro adds: maildesk.update_queue ── outbound mutations (flag/move/delete) back to the provider
Bus: maildesk.bus_orchestrator → bus.bus._sendone(<recordset channel>) → open OWL UI refresh ── §4
Two flows:
- Inbound (Basic, one-way): provider →
maildesk.message_index(SSOT) →maildesk.ui_cache(bodies) and →maildesk.ingest_queue→ Odoomail.thread. - Outbound (Pro, two-way): a user mutation (read/flag/move/delete) writes
maildesk.email_stateand enqueues a coalesced job onmaildesk.update_queue; per-provider executors apply it back to the provider.
2. Data model — SSOT, cache, queue, lease
2.1 maildesk.message_index — the single source of truth
Every message MailDesk knows about is one row in maildesk.message_index (models/message_index.py). Its
identity is the SQL unique constraint:
_sql_constraints = [
("account_folder_uid_unique",
"unique(account_id, folder, uid)",
"Message UID must be unique per account/folder"),
]
provider is a stored field on the same row (gmail / outlook / imap); the conceptual "delivery key" is
(account, provider, folder, uid), but the enforced DB key is (account_id, folder, uid). Writes go through
upsert_one(vals), which does an INSERT ... ON CONFLICT (account_id, folder, uid) DO UPDATE so re-seeing a
message is idempotent (message_index.py upsert_one). The SSOT stores metadata only (from/to/cc, subject,
date, message-id, flags, folder) — never the body.
2.2 maildesk.ui_cache — body/attachment TTL cache
Bodies and attachment lists live in maildesk.ui_cache (models/maildesk_ui_cache.py), one row per indexed
message (unique(index_id)), with a cache_until TTL (default +1 h on create; body upserts use
maildesk.cache_retention_days, default 30 days). Concurrent writes are serialized with PostgreSQL
transaction-level advisory locks:
| Lock helper | Key | When |
|---|---|---|
acquire_index_lock(index_id) |
(_CACHE_LOCK_NAMESPACE << 32) | index_id, namespace 48231 |
IMAP and Drafts (lock by row) |
acquire_message_lock(account, provider, uid) |
blake2b(account:provider:uid) |
Gmail/Outlook (same message in multiple virtual folders) |
acquire_cache_lock_for_index(index_id) |
dispatches to one of the above | generic entry point |
(pg_advisory_xact_lock(...), auto-released at transaction end.) Daily cleanup
(_cron_cleanup_expired_cache) deletes rows past cache_until except entries whose message is linked to an
Odoo record (a linked email's body must survive TTL eviction); the SSOT row is never touched, so an evicted
body is simply re-fetched from the provider on next open.
2.3 maildesk.ingest_queue — durable SSOT → mail.thread
maildesk.ingest_queue (models/ingest_queue.py) is a durable, retrying queue that materializes selected SSOT
rows into Odoo's native mail.thread / mail.message. One row per message (unique(index_id)), ordered
priority asc, id asc, with a state machine (pending → in_progress → done | failed | skipped),
retry_count, next_try_at, and started_at. Two crons drive it (see §3): cron_scan_aliases promotes
indexed messages into the queue; cron_import(batch=40, max_attempts=5) first recovers stuck in_progress
jobs (30-min timeout) then processes a batch.
2.4 maildesk.account_lease — multi-worker sync lock
maildesk.account_lease (models/maildesk_account_lease.py) is a DB-backed, time-boxed lease giving
one sync per account at a time across cron workers. unique(account_id); try_acquire(account_id, ttl_seconds)
does an INSERT ... ON CONFLICT (account_id) DO UPDATE ... WHERE lease_until < now() with lock_timeout = '0ms'
(callers never wait — lock contention 55P03/57014 returns False). renew, release, and reap_expired
(via FOR UPDATE SKIP LOCKED) round it out. The cron entrypoints pass lease_ttl=300 (5 minutes), so a crashed
worker's lease self-expires.
Pro (two-way) additions:
maildesk.update_queuecoalesces outbound mutations per delivery target, lease-locked, processed every 2 minutes, max 8 attempts with backoff;maildesk.email.linkrecords email↔Odoo-record links and is what protects linked bodies from cache eviction (§2.2). See Architecture andmaildesk_mail_client_pro/models/maildesk_update_queue.py.
3. The nine scheduled jobs (Basic, data/ir_cron.xml)
All nine are defined in maildesk_mail_client/data/ir_cron.xml as ir.cron records with state = code. The
cadence and the exact server-side call are reproduced verbatim from that file:
| # | Cron name |
Cadence | Code called |
|---|---|---|---|
| 1 | MailDesk Gmail Incremental Sync | 2 min | model.cron_gmail_history_sync(batch=5, lease_ttl=300) |
| 2 | MailDesk IMAP INBOX Sync | 3 min | model.cron_imap_inbox_incremental_sync(batch=5, lease_ttl=300) |
| 3 | MailDesk Outlook Delta Sync | 3 min | model.cron_outlook_delta_sync(batch=5, lease_ttl=300) |
| 4 | MailDesk Outlook Sparse Metadata Repair | 15 min | model.cron_outlook_repair_sparse_metadata(batch=3, row_limit=50, lease_ttl=300) |
| 5 | MailDesk: Alias Scan (Index → Queue) | 3 min | model.cron_scan_aliases() |
| 6 | MailDesk: Process Queue (Odoo Import) | 3 min | model.cron_import(batch=40, max_attempts=5) |
| 7 | MailDesk: Bootstrap Pending Folders | 4 min | model.bootstrap_if_pending() |
| 8 | MailDesk: Progressive Backfill | 4 min | inline code: FOR UPDATE SKIP LOCKED then progressive_backfill() |
| 9 | MailDesk: Clean Expired Cache | daily (1 day) | model._cron_cleanup_expired_cache() |
Notes worth knowing:
- Jobs 1–4 are per-provider entrypoints on
mailbox.account. Gmail uses the History API, Outlook the Graph delta-link, IMAP the UID/CONDSTORE path; each acquires the account lease (lease_ttl=300) before touching the provider. The Outlook sparse-metadata repair (job 4) backfills thin metadata rows the delta path left behind. - Jobs 5 + 6 are the ingest pipeline (§2.3): job 5 promotes SSOT rows into the queue, job 6 materializes the
queue into
mail.thread(recovering stuck jobs first). - Jobs 7 + 8 fill folder history. Job 7 bootstraps a folder's newest messages so it is usable in seconds;
job 8's cron body selects up to 5
incrementalfolders withbackfill_completed_at = False, takes aFOR UPDATE SKIP LOCKEDlock on them, then callsprogressive_backfill()(newest-first, lower priority than new mail) so two workers never backfill the same folder. - Job 9 is the daily TTL cache cleanup (§2.2).
The in-module note
docs/dataflow/sync_flow.mdpredates this cron set and quotes some older cadences (e.g. "every 5 minutes" / "3 am"). When the two disagree,data/ir_cron.xmlis authoritative — the table above is the live schedule.
4. In-app freshness — the bus
Once a sync has indexed a message, the open UI is refreshed in near-real-time over Odoo's websocket bus, not by re-polling. This is UI push, and it only fires after a scheduled sync has done the provider work — it does not itself reach out to the provider.
4.1 Channels are access-gated recordset channels
models/ir_websocket.py overrides ir.websocket._build_bus_channel_list. The frontend may request the string
channels "mailbox.account_<id>" and "maildesk.user"; the override maps those to recordset channels the
current user is actually allowed to see:
"mailbox.account_<id>"→ themailbox.accountrecordset, but only when the user is a mailbox admin or the account is in theiraccess_user_ids(per-mailbox isolation). Otherwise it is dropped."maildesk.user"→ the current user's ownres.usersrecordset (private notifications).
Using recordset channels (not raw guessable strings) means the bus subscription is ACL-checked, the way Odoo
Mail gates discuss.channel_*.
4.2 Every emission goes through one orchestrator
All bus writes funnel through maildesk.bus_orchestrator (models/orchestration/bus_orchestrator.py), whose
dispatch(channel, type, payload) is the single emission boundary. It refuses string channels, refuses any
target model other than {"mailbox.account", "res.users"}, and re-checks access (access_user_ids for account
events; self-only for user events) before calling bus.bus._sendone(channel, type, payload). The standardized
event types it emits:
| Method | Notification type | Meaning |
|---|---|---|
notify_messages_added |
maildesk.account/messages_added |
new SSOT rows; UI re-pulls the list |
notify_flags_changed |
maildesk.account/flags_changed |
read/star/flag deltas |
notify_messages_moved |
maildesk.account/messages_moved |
folder move |
notify_messages_deleted |
maildesk.account/messages_deleted |
deletion |
notify_tags_changed |
maildesk.account/tags_changed |
tag add/remove |
notify_folder_tree_changed |
maildesk.account/folder_tree_changed |
folder discovery |
notify_unread_count_changed |
maildesk.account/unread_count_changed |
absolute unread badge |
notify_refresh / notify_full_refresh |
maildesk.account/refresh |
"pull latest SSOT" |
notify_user_new_messages |
maildesk.notify/new_messages |
desktop notification (user channel) |
notify_admin_attention |
maildesk.notify/admin_attention_required |
account escalated after repeated sync failures (owner's user channel) |
The legacy BusNotificationAdapter (infrastructure/adapters/bus_notification_adapter.py) is the use-case-side
shim that calls these from inside the sync use cases.
5. The three orchestrators
MailDesk's sync control plane is three AbstractModel orchestrators under models/orchestration/, each with a
single responsibility:
| Orchestrator | Model | Responsibility |
|---|---|---|
| Polling | maildesk.polling_orchestrator |
decides when sync happens. run_cron() (cron entry) calls sync_all(); heartbeat() (UI poll) syncs the current user's own accounts. |
| Sync | maildesk.sync_orchestrator |
decides what to sync. sync_all() selects active accounts whose backoff_until has passed; sync_account(id) resolves the provider and dispatches. It is IMAP-only — Gmail/Outlook have their own cron entrypoints (jobs 1+3) and must not fall back to IMAP auth. IMAP dispatch is two-phase: cheap ScanImapFolders (STATUS) then targeted SyncImapFolderIncremental of dirty folders, each in its own savepoint; serialization errors (pgcode 40001) re-raise, everything else is logged and isolated per phase. |
| Bus | maildesk.bus_orchestrator |
decides what is broadcast — the access-gated emission boundary from §4.2. |
A weak spot to know: sync_orchestrator._dispatch_gmail / _dispatch_outlook are stubs (return) — Gmail and
Outlook do not route through this orchestrator at all; they are driven directly by their dedicated crons
(jobs 1, 3, 4). Treat sync_orchestrator as the IMAP control plane specifically.
6. Bootstrap, backfill, and failure handling
- Bootstrap (job 7,
bootstrap_if_pending) fills a folder's newest messages first so the inbox is usable in seconds; it leaves the older range to progressive backfill. Folder sync state is tracked onmailbox.folder(sync_state,backfill_completed_at,backfill_fetched_count); a per-folder advisory transaction lock (namespace 48232,pg_try_advisory_xact_lock) prevents two writers from fighting over the same folder (models/mailbox_folder.py,application/use_cases/sync_imap_folder.py). - Progressive backfill (job 8) walks history newest-first under a
FOR UPDATE SKIP LOCKEDselection and a per-run UID/time budget, enqueuing at lower ingest priority so new mail always wins. Tunable via themaildesk.backfill_*config parameters (backfill_batch_size,backfill_job_budget_seconds,backfill_max_total_per_folder,backfill_mode,backfill_bootstrap_max). - Per-account backoff is honored at the orchestrator gate:
sync_all()skips accounts with a futurebackoff_until, and repeated IMAP sync failures escalate tomailbox.account.needs_admin_attentionplus anotify_admin_attentionbus event to the account owner.
Config parameters (ir.config_parameter, defaults)
| Parameter | Default | Effect |
|---|---|---|
maildesk.cache_retention_days |
30 | body cache TTL (days) |
maildesk.sync_batch_size |
100 | sync fetch batch |
maildesk.ingest_batch_size |
50 | ingest batch |
maildesk.list_page_limit |
30 | UI list page size |
maildesk.backfill_bootstrap_max |
1500 | bootstrap ceiling |
maildesk.backfill_batch_size |
100 | backfill batch |
maildesk.backfill_job_budget_seconds |
25 | backfill per-run time budget |
maildesk.backfill_max_total_per_folder |
20000 | backfill ceiling per folder |
maildesk.backfill_mode |
progressive | progressive or bootstrap_only |
7. What 4.2.0 changes (pointer)
4.2.0 keeps this entire pipeline as the fallback path and adds, on top: true Gmail/Outlook push (webhooks
that trigger the existing sync crons), IMAP "autopilot" smart polling, and body prefetch into
maildesk.ui_cache. Push shortens latency; it does not replace §3's jobs or §4's bus. Full detail and status
(not-yet-merged, MRs !237/!124) is in Realtime architecture.