MailDesk docs
Get MailDesk
Technical

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).

8 min read

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 (returns None); 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.

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 target check_access("read"); unreadable rows are silently dropped, not linked. The service sudo()s the write after the access check.
  • Dedupe: existing (message_index_id, model, res_id) triples are looked up and skipped before create; re-linking is idempotent.
  • Primary/secondary promotion/demotion/normalization is done here, not in the bridges.

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) (default true),
  • computes props via getProps(context) (default {}),
  • returns { key, Component, props } ordered by registration sequence.

Contract rules:

  • Component is 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-v18 in both Basic and Pro (MRs !237 Basic, !124 Pro). They are not in 18.0 HEAD. 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.