MailDesk docs
Get MailDesk
Technical

HTTP controllers

MailDesk is a single-page OWL app: the bulk of its server traffic is JSON-RPC callkw into the models (documented in Module reference and the model pages). The dedicated http.Controller routes are deliberately few — an app entry redirect, the Outlook OAuth handshake, a service-worker extension, and (in Pro) an "Ask AI" override. The 4.2.0 line adds two inbound push webhooks; those are flagged as near-release throughout.

8 min read

MailDesk is a single-page OWL app: the bulk of its server traffic is JSON-RPC call_kw into the models (documented in Module reference and the model pages). The dedicated http.Controller routes are deliberately few — an app entry redirect, the Outlook OAuth handshake, a service-worker extension, and (in Pro) an "Ask AI" override. The 4.2.0 line adds two inbound push webhooks; those are flagged as near-release throughout.

All controller files carry the proprietary Metzler IT license header. Routes below are grouped by module.


1. Basic (maildesk_mail_client)

Three controller files: controllers/maildesk.py, controllers/outlook_graph_auth.py, controllers/webmanifest.py (all registered in controllers/__init__.py).

1.1 App entry — GET /maildesk

Route /maildesk
Type http (website=True)
Auth auth="user"
Method GET
Handler MailDeskController.open_maildesk (controllers/maildesk.py)

Permission-gated entry point. If the logged-in user is not in maildesk_mail_client.group_mailbox_user, the controller redirects to /web (the Odoo home) rather than exposing the feature; members are redirected to the client action /web#action=maildesk_mail_client.maildesk_action.

if not request.env.user.has_group("maildesk_mail_client.group_mailbox_user"):
    return request.redirect("/web")
return request.redirect("/web#action=maildesk_mail_client.maildesk_action")

Security note: authorization is group-membership based, checked server-side before redirect; there is no anonymous path to the action.

1.2 Outlook OAuth — start the flow — GET /maildesk/outlook/authorize

Route /maildesk/outlook/authorize
Type http
Auth auth="user"
Method GET
Handler MicrosoftOutlookConfirmController.authorize (controllers/outlook_graph_auth.py)
Input account_id (required), provider (default maildesk_outlook)
Output HTTP redirect to the Microsoft authorize URL

Looks up mailbox.account.browse(int(account_id)); returns request.not_found() if it does not exist. Builds the callback URI from web.base.url (/microsoft_outlook/confirm) and delegates the authorize URL to infrastructure/providers/outlook/auth_service.get_auth_url, passing scopes from self._get_graph_scopes(provider). The state payload generated by get_auth_url embeds {provider, account_id, csrf}, where csrf = account.mail_server_id._get_outlook_csrf_token().

Scopes (Basic): BASIC_GRAPH_SCOPES = ("offline_access", "https://graph.microsoft.com/Mail.Read") (read-only). Pro overrides this hook — see §2.2.

1.3 Outlook OAuth — callback — GET /microsoft_outlook/confirm

Route /microsoft_outlook/confirm
Type http
Auth auth="user"
Method GET
Handler MicrosoftOutlookConfirmController.microsoft_outlook_callback
Input code, state, error, error_description (query params from Microsoft)
Output HTTP redirect (to the mailbox.account form, or to the patched Odoo record)

This single callback serves two flows and routes by the decoded state:

  1. On a provider error, it returns the error text.
  2. With missing state or code it raises Forbidden.
  3. It json.loads(state); if the payload is a dict carrying a provider that starts with maildesk_, it dispatches to _handle_maildesk_outlook; otherwise to _handle_odoo_outlook (the standard Odoo fetchmail/record path). A non-maildesk_ provider value raises Forbidden.

_handle_maildesk_outlook (MailDesk accounts): - Re-browses mailbox.account from data["account_id"]; Forbidden if absent. - CSRF check via constant-time compare: consteq(data.get("csrf"), account.mail_server_id._get_outlook_csrf_token())Forbidden on mismatch. - Exchanges the code through auth_service.exchange_code(...) with the resolved scopes. - Computes token expiry with a safety margin (TOKEN_EXPIRY_SAFETY_SECONDS) and persists tokens only on mailbox.account.outlook_graph_* fields; resets sync cursors (outlook_delta_tokens, outlook_delta_link, backoff_until) and stamps maildesk_sync_requested_at. - Strict isolation: if the linked mail_server_id is an outlook server, it clears the legacy Odoo microsoft_outlook_* tokens so the stock Odoo IMAP/Outlook fetch path cannot take over the mailbox. - Redirects to /web#id={account.id}&model=mailbox.account&view_type=form.

_handle_odoo_outlook (stock Odoo records): - Requires base.group_system; otherwise Forbidden. - Decodes {model, id, csrf_token} from state, browses the record, re-checks the CSRF token with consteq against record._get_outlook_csrf_token(), fetches the refresh token via record._fetch_outlook_refresh_token(code), writes the standard microsoft_outlook_* fields, and redirects to /odoo/{model}/{id}.

Security notes: - Both branches use constant-time CSRF comparison (odoo.tools.consteq). - MailDesk and stock-Odoo token stores are kept strictly separate; reconnecting a MailDesk account actively clears stock tokens to prevent a silent fallback path. - State decode failures are logged without the payload and treated as Forbidden/no-op.

1.4 Service-worker extension — _get_service_worker_content override

controllers/webmanifest.py extends Odoo's mail WebManifest controller. It does not add a new route; it overrides _get_service_worker_content on the existing service-worker endpoint.

Base odoo.addons.mail.controllers.webmanifest.WebManifest
Override WebManifest._get_service_worker_content
Gate request.env.user.has_group("maildesk_mail_client.group_mailbox_user")

For mailbox users it prepends static/src/service_worker/maildesk_service_worker.js to the native service-worker body (read via odoo.tools.file_open). The MailDesk handler only intercepts MailDesk notification payloads (MAILDESK_NOTIFICATION_TYPE = "maildesk.message", a notificationclick listener that opens the MailDesk deep link) and falls through for all native Odoo notification types. Non-members get the unmodified Odoo service worker.


2. Pro (maildesk_mail_client_pro)

On the shipped 4.1.x line Pro adds no new public route. It contributes one AI override controller (controllers/html_editor_override.py) and one scope-hook override for the Basic Outlook callback (controllers/outlook_graph_auth_pro.py). The 4.2.0 line adds the two push webhooks in §2.3.

2.1 "Ask AI" override — POST /web_editor/generate_text + /html_editor/generate_text

Routes /web_editor/generate_text, /html_editor/generate_text
Type type="json"
Auth auth="user"
Handler MaildeskHtmlEditorController.generate_text (controllers/html_editor_override.py)
Base odoo.addons.html_editor.controllers.main.HTML_Editor (subclassed, not replaced)
Input prompt: str, conversation_history: list ([{role, content}])
Output plain string (generated text) — identical shape to the upstream controller

Re-registers the upstream HTML-editor AI route so Odoo's "Ask AI" / ChatGPT dialog is routed through MailDesk providers when one is configured. Because the class inherits HTML_Editor, every other route on that controller (e.g. /html_editor/get_ice_servers) is left untouched.

Routing logic: 1. Build a MaildeskAIService(env) and resolve the provider for task key html_editor_generate. 2. Kill-switch guard: assert_ai_allowed(env, feature="html_editor_generate", require_provider=False) (the single AI authorization chokepoint). On AIDisabledError, fall back to Odoo's IAP/OLG path. 3. Provider-ready guard: build_adapter(env, provider_key). If no provider has an API key (AIAuthError/AIProviderError) — or on any unexpected error — fall back to IAP. 4. Normalize the dialog history into validated AIMessage turns; inject a generic system message only if the history carries no stronger MailDesk thread-specific system context. The injected system prompt instructs the model that email content arriving inside <UNTRUSTED_EMAIL_DATA> tags is data, never instructions (prompt-injection hardening). 5. Dispatch via service.complete_task(...) and return response.content. Provider errors fall back to IAP.

IAP fallback (_maildesk_generate_via_iap) mirrors the upstream controller exactly: iap_tools.iap_jsonrpc(olg_endpoint + "/api/olg/1/chat", ...) using web_editor.olg_api_endpoint (default https://olg.api.odoo.com) and database.uuid, preserving the upstream UserError messages for error_prompt_too_long / limit_call_reached.

Security notes: auth="user"; the AI kill switch and per-mailbox allow_ai gating are enforced server-side (see AI permissions & data access); a missing or broken provider degrades transparently to Odoo IAP rather than erroring the dialog.

2.2 Outlook scope-hook override (no new route)

controllers/outlook_graph_auth_pro.py subclasses the Basic MicrosoftOutlookConfirmController as MicrosoftOutlookConfirmControllerPro and overrides only _get_graph_scopes. By Odoo's controller-inheritance rules it does not register any new callback route — it reuses §1.2/§1.3 with a wider scope.

def _get_graph_scopes(self, provider):
    return PRO_GRAPH_SCOPES

Scopes (Pro): PRO_GRAPH_SCOPES = ("offline_access", "https://graph.microsoft.com/Mail.ReadWrite") (from infrastructure/providers/outlook/mutation_executor.py). The read-write scope is what enables Pro's two-way sync (flag/move/soft-delete pushed back to Microsoft Graph). The same constant is also used by models/mailbox_account.py (_maildesk_outlook_oauth_scopes).


3. 4.2.0 push webhooks — near-release, NOT yet merged

Status banner. The two webhooks below live on branch MailDesk-889-realtime-sync-v18 (Pro), tracked by MR !124, alongside the Basic counterpart on MailDesk-889-realtime-sync-v18 / MR !237. 18.0 HEAD does not contain these commits — treat this section as forward-looking until the MRs merge. Source verified via read-only git show MailDesk-889-realtime-sync-v18:maildesk_mail_client_pro/controllers/{gmail_push,outlook_push}.py. Both webhooks are registered in the branch's controllers/__init__.py.

Both controllers are intentionally thin (presentation layer): authenticate/validate, cap the body, parse JSON, hand off to a use case, and return 2xx fast. Neither does a provider fetch in the request — the use case stamps the account and triggers the existing sync cron, so push shortens latency without replacing the §1 pipeline (see Realtime architecture).

Both share a body cap: MAX_BODY_BYTES = 65_536413 payload_too_large if exceeded.

3.1 Gmail Pub/Sub webhook — POST /maildesk/push/gmail

Route /maildesk/push/gmail
Type http
Auth auth="public", csrf=False, methods=["POST"], save_session=False
Handler GmailPushController.gmail_push
Caller Google Cloud Pub/Sub (not a browser)
Hand-off HandleGmailPush(request.env(su=True)).execute(envelope)

Flow: 1. Extract the Authorization: Bearer <token> header → 401 missing_bearer if absent. 2. Read config: maildesk.pro.push.gmail.audience (falls back to maildesk.pro.push.gmail.webhook_url) and the signer allowlist maildesk.pro.push.gmail.allowed_signer_emails. Missing audience or empty signer allowlist → 503 config_missing (fail-closed). 3. OIDC verification: verify_pubsub_oidc(bearer_token, expected_audience, allowed_service_account_emails) (infrastructure/push/push_jwt.py) checks the JWT signature, audience, issuer, and signer allowlist. On PubSubVerificationError403 forbidden (failure category logged, never the token or body). 4. Parse the JSON envelope (400 bad_json on failure) and hand off to the HandleGmailPush use case as superuser. Handler exceptions are swallowed to 202 accepted (so Pub/Sub does not hammer-retry on transient errors); success returns 202 with {accepted, reason, account_id}.

Security notes: auth="public" + csrf=False are required for a machine-to-machine webhook; authenticity comes from the OIDC JWT check, not a session. Config is fail-closed (no audience / no signer allowlist → reject). The token and request body are never logged.

3.2 Outlook Graph webhook — POST /maildesk/push/outlook

Route /maildesk/push/outlook
Type http
Auth auth="public", csrf=False, methods=["POST"], save_session=False
Handler OutlookPushController.outlook_push
Caller Microsoft Graph change-notifications (not a browser)
Hand-off HandleOutlookPush(request.env(su=True)).execute(notifications)

Flow: 1. Validation handshake: when Graph creates a subscription it POSTs once with a validationToken query param; the controller echoes it back verbatim as text/plain HTTP 200 (the token is never persisted). 2. Body cap → 413 payload_too_large. 3. Parse JSON: 400 bad_json on malformed body, 400 bad_shape if the payload is not an object or value is not a list. Graph notifications must be {"value": [...]}. 4. Hand off the notifications list to HandleOutlookPush as superuser. Authenticity is enforced inside the use case via a constant-time clientState comparison (the secret set when the subscription was created), not in the controller. Handler exceptions return 202 accepted (Graph replays on 5xx and can throttle the channel); success returns 202 with {accepted, rejected, triggered, lifecycle}.

Security notes: auth="public" + csrf=False are required for the Graph caller; the trust anchor is the clientState check in the use case. Always answering 2xx (even on handler error) is deliberate to keep Graph from throttling the subscription. Malformed JSON is logged without the body.


4. Route summary

Route Module Type Auth Method Status
/maildesk Basic http (website) user GET shipped
/maildesk/outlook/authorize Basic http user GET shipped
/microsoft_outlook/confirm Basic http user GET shipped
(service-worker content) Basic override user-gated shipped
/web_editor/generate_text, /html_editor/generate_text Pro json user POST shipped
(Outlook scope hook) Pro override of §1.2/1.3 user shipped
/maildesk/push/gmail Pro http public + OIDC POST 4.2.0 — not yet merged
/maildesk/push/outlook Pro http public + clientState POST 4.2.0 — not yet merged