Extension points
This page documents the extension surfaces that are live in the shipped v18 code today — the supported ways an addon enriches MailDesk without overriding core entrypoints or fighting Python MRO. Each surface is a deliberate contract; the developer-only ADRs under internal/architecture/adr/ carry the rationale (they are written as "Proposed" design records, but the contracts below are implemented and in use — this page is the code reality, not the proposal).
This page documents the extension surfaces that are live in the shipped v18 code today — the supported
ways an addon enriches MailDesk without overriding core entrypoints or fighting Python MRO. Each surface is
a deliberate contract; the developer-only ADRs under internal/architecture/adr/ carry the rationale (they
are written as "Proposed" design records, but the contracts below are implemented and in use — this
page is the code reality, not the proposal).
Audience: developers. Model / field / method / route / file names below are code references, verified against source. Do not invent fields or methods; the footer lists exactly what was checked.
The four stable surfaces:
| Surface | Module | What it replaces | ADR |
|---|---|---|---|
Post-send orchestrator (PostSendOrchestrator + maildesk.post.send.handler.*) |
Basic dispatches; Pro/bridges implement | send_email() super-chains |
ADR-006 |
Account DTO enrichment (AccountDTOBuilder + _maildesk_account_row_enrich) |
Basic owns base; Pro/bridges enrich | super().get_account_list() mutation |
ADR-008 |
Link platform (LinkPlatformService, single write-path for maildesk.email.link) |
Pro | per-bridge direct link persistence | ADR-003 / ADR-012 |
Frontend detail registry (mail_detail_registry.esm.js) |
Basic | stacked OWL template patches | ADR-009 |
1. Post-send orchestration
Rationale (ADR-006): post-send side effects used to live in stacked send_email() overrides, making
ordering and idempotency MRO-dependent. The shipped code replaces that chain with one canonical dispatch
event and a fixed set of deterministic handler models.
How it works
The Basic send use case (application/use_cases/send_email.py) constructs a PostSendOrchestrator once
(self._post_send = PostSendOrchestrator(env)) and, after the provider send succeeds, calls it exactly
once:
# maildesk_mail_client/application/use_cases/send_email.py
self._post_send.dispatch(
account=account,
send_params=params,
send_result={"message_id": mid, "outgoing_id": outgoing_id},
draft=draft,
)
PostSendOrchestrator (application/services/post_send_orchestrator.py) normalizes send_params to a
plain dict (so handlers can treat dataclass or dict callers identically), builds one payload, and dispatches
to a fixed, ordered tuple of handler models — skipping any not in the registry and swallowing per-handler
exceptions at WARNING so a failing side effect never fails the send:
_HANDLER_MODELS = (
"maildesk.post.send.handler.automation",
"maildesk.post.send.handler.chatter",
"maildesk.post.send.handler.composer_link",
"maildesk.post.send.handler.crm",
"maildesk.post.send.handler.helpdesk",
)
The payload passed to each handler:
| Key | Type | Source |
|---|---|---|
account |
mailbox.account recordset |
the sending account |
send_params |
dict |
normalized SendEmailParams (or legacy dict) |
send_result |
dict |
{"message_id", "outgoing_id"} |
draft |
maildesk.draft or None |
the draft consumed, if any |
Implementing a handler
Declare an AbstractModel whose _name is one of the five tuple entries and implement
handle_post_send(self, payload). The canonical reference implementation is the Pro composer-link handler:
# maildesk_mail_client_pro/models/post_send_handler.py
class MaildeskPostSendHandlerComposerLink(models.AbstractModel):
_name = "maildesk.post.send.handler.composer_link"
_description = "MailDesk Post-Send Composer-Link Handler"
_locker_service_protected = True
@api.model
def handle_post_send(self, payload):
# ... reads bound_model / bound_res_id / message_id from the payload,
# creates a maildesk.email.link via mailbox.sync; failures are
# logged at WARNING and swallowed — the send already succeeded.
Contract rules:
- The model name must be one of
_HANDLER_MODELS. The orchestrator does not auto-discover arbitrary models — a new handler family requires adding the name to the tuple in core (a reviewed change). - A handler must not raise to fail the send. Catch and log; the orchestrator also guards with a try/except, but defensive handlers keep the warning attributable.
- Order is the tuple order — deterministic, not MRO-derived.
There is also a no-op convenience hook
mailbox.account._maildesk_after_send_dispatch(account, send_params, send_result, draft=None)in Basic (returnsNone); it exists so future slices can migrate behavior behind a stable model method without touching the use case again. The orchestrator is the live path today.
2. Account DTO enrichment
Rationale (ADR-008): the frontend account payload used to be shaped by inheritance-based
get_account_list() overrides that each super()-mutated the row, hiding field ownership. The shipped code
makes Basic own a canonical base row and gives addons a single enrichment hook.
How it works
get_account_list() in Basic (models/mailbox_account.py) is the only entrypoint and delegates row
composition to AccountDTOBuilder:
# maildesk_mail_client/models/mailbox_account.py
@api.model
def get_account_list(self):
accounts = self.search(
[("access_user_ids", "in", [self.env.uid])], order="sequence, name"
)
return AccountDTOBuilder(self.env).build(accounts)
AccountDTOBuilder (application/services/account_dto_builder.py) builds each row in two steps — base then
enrich:
row = account_model._maildesk_account_row_base(account)
row = account_model._maildesk_account_row_enrich(account, row)
Core owns the base shape (_maildesk_account_row_base): id, name, email, sender_name, signature,
is_shared, block_tracking_urls, sequence. The base _maildesk_account_row_enrich is a pure no-op that
returns dict(row).
Adding owned fields
Override _maildesk_account_row_enrich(account, row), call super() first, then add your owned keys.
Pro is the reference:
# maildesk_mail_client_pro/models/mailbox_account.py
def _maildesk_account_row_enrich(self, account, row):
row = super()._maildesk_account_row_enrich(account, row)
acc = account.sudo()
row["allow_ai"] = bool(acc.allow_ai)
row["ai_team_context"] = acc.ai_team_context or ""
row["ai_reply_tone"] = acc.ai_reply_tone or "professional"
return row
Contract rules:
- Do not override
get_account_list()— the docstring on the method states this explicitly. Enrich only. - Always
super()first, then add keys you own; never delete or rewrite keys another layer owns. - The base row is the compatibility surface — frontend code may depend on every base key being present.
3. Link platform (maildesk.email.link)
Rationale (ADR-003 / ADR-012): email-to-record links (maildesk.email.link) had multiple write paths
and each bridge re-implemented primary/secondary normalization. The shipped code routes all link
mutations through one Pro service so dedupe, visibility (ACL), and primary/secondary semantics are
centralized.
The single write path
LinkPlatformService (maildesk_mail_client_pro/application/services/link_platform_service.py) is the one
place that touches maildesk.email.link. It is constructed with env and exposes the full link API; callers
(models/mailbox_sync.py, models/maildesk_chatter.py, the post-send composer-link handler) go through it
rather than create/write on the model directly.
Representative methods:
| Method | Purpose |
|---|---|
link_records(*, index_ids, model, res_ids) |
Bulk create links; ACL-filters both index rows and targets, dedupes against existing |
link_single_record(*, index_id, model, res_id) |
Single link create (DTO result) |
unlink_link(*, link_id) / unlink_single_record(*, index_id, model, res_id) |
Remove links |
get_visible_links(*, index_id) |
Current-user-visible link DTOs for one message |
links_for_message_model / links_for_record |
Listing |
count_links_for_message_model / count_links_for_record / has_link / find_link |
Counts / existence |
normalize_primary_secondary(...) / link_record_with_primary_semantics(...) / unlink_record_with_primary_semantics(...) |
Platform-owned primary/secondary semantics (ADR-012) |
Built-in guarantees worth relying on (verified in link_records):
- ACL fail-safe: every index row gets
check_access("read")and every targetcheck_access("read"); unreadable rows are silently dropped, not linked. The servicesudo()s the write after the access check. - Dedupe: existing
(message_index_id, model, res_id)triples are looked up and skipped beforecreate; re-linking is idempotent. - Primary/secondary promotion/demotion/normalization is done here, not in the bridges.
Adding a domain link
Bridges (CRM, Helpdesk, Sale, Documents, Calendar) must not create maildesk.email.link rows
themselves. Instantiate the service and call it:
from ..application.services.link_platform_service import LinkPlatformService
LinkPlatformService(self.env).link_records(
index_ids=[idx_id], model="crm.lead", res_ids=[lead_id]
)
For domains that participate in primary/secondary families (CRM / Helpdesk / Calendar per ADR-012), use
link_record_with_primary_semantics(...) so promotion/demotion stays centralized.
4. Frontend detail registry
Rationale (ADR-009): the message-detail surface used to be extended by stacked OWL template patches, which collided and hid ownership. The shipped code provides a deterministic registry-based composition model for the detail view.
The registries
static/src/registries/mail_detail_registry.esm.js (Basic) declares four named categories on Odoo's
@web/core/registry and a typed register*Contributor helper per category:
| Registry category | Slot | Register helper |
|---|---|---|
maildesk.detail.toolbar |
toolbar actions | registerMailDetailToolbarContributor(key, contributor, opts) |
maildesk.detail.header_before_subject |
header, before the subject line | registerMailDetailHeaderContributor(...) |
maildesk.detail.inline_cards |
inline cards in the body | registerMailDetailInlineCardContributor(...) |
maildesk.detail.side_panels |
side panels | registerMailDetailSidePanelContributor(...) |
Contract
A contributor is a plain object; the registry shapes it into a definition { key, ...contributor } and
stores it with a sequence (from opts.sequence, else contributor.sequence, else 100):
import { registerMailDetailSidePanelContributor }
from "@maildesk_mail_client/registries/mail_detail_registry.esm";
registerMailDetailSidePanelContributor("partner360", {
Component: Partner360Panel, // required — entries without Component are skipped
isVisible: (ctx) => ctx.hasPartner, // optional predicate, default visible
getProps: (ctx) => ({ partnerId: ctx.partnerId }), // optional, default {}
sequence: 20,
});
At render time the host component calls resolveMailDetailRegistryEntries(category, context), which:
- iterates
category.getAll(), - skips any entry without
Component, - evaluates
isVisible(context)(defaulttrue), - computes
propsviagetProps(context)(default{}), - returns
{ key, Component, props }ordered by registrationsequence.
Contract rules:
Componentis required — entries without it are dropped, not errored.- Ordering is
sequence, ascending; collisions resolve to registration order. - Keep contributors stateless w.r.t. each other — composition is deterministic but unordered side effects between contributors are not a contract.
Coming in 4.2.0 (branch-only, not merged)
Status: the hooks below live on branches
MailDesk-889-realtime-sync-v18/MailDesk-prefetch-cache-v18in both Basic and Pro (MRs !237 Basic, !124 Pro). They are not in18.0HEAD. Treat this section as forward-looking; see Realtime architecture §2.3 for the surrounding pipeline.
4.2.0 adds two backend hook methods on mailbox.account that feed the body-prefetch queue
(maildesk.message_prefetch_queue). Both are no-ops in Basic and given real implementations in Pro — the
same base/override pattern as the account-DTO hook, so they are extension points by construction.
Post-upsert hook — priority 1
# Basic: no-op base
def _maildesk_after_message_index_upserted(self, *, account_id, folder, uid, index_id, is_new):
"""No-op hook after a SSOT row is upserted. Overridden by addons."""
return None
Called by the sync use cases (sync_imap_folder.py / sync_gmail_incremental.py / sync_outlook_delta.py)
after each SSOT upsert. Pro overrides it to enqueue newly-inserted rows for body prefetch at priority 1
(reason="sync_new") via maildesk.message_prefetch_queue.ensure_queued(...). It only enqueues — it does not
notify the bus or re-index. Note the keyword-only signature (*): account_id, folder, uid,
index_id, is_new.
Folder-open prefetch hook — priority 5
# Basic: no-op base
def _maildesk_enqueue_folder_open_prefetch(self, *, account_id, folder_id, top_index_ids):
"""No-op hook after a folder list is rendered. Overridden by addons."""
return None
Called when a folder list renders (from models/mailbox_sync.py). Pro enqueues the top-N visible messages
for prefetch at priority 5 (reason="folder_open"); N comes from config param
maildesk.prefetch.folder_open_top_n (default 30). Pro filters the candidates to the same account and
non-deleted_on_server rows before queueing, and swallows per-row enqueue failures.
Same contract as the stable account-DTO hook: call super() first in any further override, the base is a
deliberate no-op so Basic-only installs incur no cost, and Pro owns the real behavior.