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.
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:
- On a provider
error, it returns the error text. - With missing
stateorcodeit raisesForbidden. - It
json.loads(state); if the payload is a dict carrying aproviderthat starts withmaildesk_, it dispatches to_handle_maildesk_outlook; otherwise to_handle_odoo_outlook(the standard Odoofetchmail/record path). A non-maildesk_provider value raisesForbidden.
_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 onMailDesk-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-onlygit show MailDesk-889-realtime-sync-v18:maildesk_mail_client_pro/controllers/{gmail_push,outlook_push}.py. Both webhooks are registered in the branch'scontrollers/__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_536 → 413 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 PubSubVerificationError → 403 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 |