velxio/backend/app/services/odoo_mail.py

131 lines
4.5 KiB
Python

"""Fire-and-forget bridge to Odoo's transactional-mail endpoints.
Velxio leans on Odoo's outgoing-mail relay (SPF/DKIM already warmed up,
templates editable from the admin UI) instead of running its own SMTP.
The Odoo-side endpoints live in `velxio_transactional_mail` addon:
POST <ODOO_URL>/velxio/api/send-welcome
POST <ODOO_URL>/velxio/api/send-password-reset
Both expect a JSON-RPC payload (Odoo's `type='json'` controllers expect
`{"jsonrpc": "2.0", "params": {...}}`) and authenticate via the
`X-Velxio-API-Key` header.
These helpers are designed to be called from `asyncio.create_task(...)`
so they NEVER raise into the request lifecycle. Any failure is logged at
WARNING level and the function returns False. The result is otherwise
ignored — registration / forgot-password succeed even when Odoo is down,
the user just doesn't get the email until ops re-runs the cron.
"""
from __future__ import annotations
import logging
from typing import Optional
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
def _is_configured() -> bool:
return bool(settings.ODOO_URL and settings.ODOO_API_KEY)
async def _post(endpoint: str, params: dict) -> Optional[dict]:
"""POST a JSON-RPC envelope to an Odoo /velxio/api/* endpoint.
Returns the decoded `result` dict on success, None on any failure.
Never raises.
"""
if not _is_configured():
logger.info("[odoo_mail] skipped %s — ODOO_URL or ODOO_API_KEY missing", endpoint)
return None
url = settings.ODOO_URL.rstrip("/") + endpoint
payload = {"jsonrpc": "2.0", "method": "call", "params": params}
headers = {
"X-Velxio-API-Key": settings.ODOO_API_KEY,
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=settings.ODOO_MAIL_TIMEOUT_S) as client:
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
logger.warning(
"[odoo_mail] %s → HTTP %s: %s",
endpoint, response.status_code, response.text[:200],
)
return None
body = response.json()
# Odoo wraps `type='json'` controllers in {"jsonrpc": "2.0", "result": ...}
# or {"error": {...}} on failure.
if "error" in body:
logger.warning("[odoo_mail] %s → error %s", endpoint, body["error"])
return None
return body.get("result")
except httpx.TimeoutException:
logger.warning("[odoo_mail] %s timed out after %ss", endpoint, settings.ODOO_MAIL_TIMEOUT_S)
except httpx.HTTPError as exc:
logger.warning("[odoo_mail] %s → HTTP error: %s", endpoint, exc)
except Exception: # noqa: BLE001 — fire-and-forget must swallow everything
logger.exception("[odoo_mail] %s — unexpected failure", endpoint)
return None
async def send_welcome(
*,
velxio_user_id: str,
email: str,
name: str,
country_code: Optional[str] = None,
editor_url: Optional[str] = None,
examples_url: Optional[str] = None,
) -> bool:
"""Ask Odoo to send the welcome mail. Returns True iff Odoo accepted
and dispatched the mail (sent=True in the response).
Idempotent: Odoo persists `velxio_welcome_sent_at` on the partner, so
a retry of this call after a successful first dispatch is a silent
no-op on the Odoo side (returns `{sent: false, reason: "already_sent"}`).
"""
params: dict = {
"velxio_user_id": velxio_user_id,
"email": email,
"name": name,
}
if country_code:
params["country_code"] = country_code
if editor_url:
params["editor_url"] = editor_url
if examples_url:
params["examples_url"] = examples_url
result = await _post("/velxio/api/send-welcome", params)
return bool(result and result.get("sent"))
async def send_password_reset(
*,
email: str,
reset_url: str,
expires_in_minutes: int = 60,
user_name: Optional[str] = None,
) -> bool:
"""Ask Odoo to deliver a password-reset mail with the given URL.
The caller (Velxio backend) is the source of truth for the one-time
token; Odoo only renders the email.
"""
params: dict = {
"email": email,
"reset_url": reset_url,
"expires_in_minutes": expires_in_minutes,
}
if user_name:
params["user_name"] = user_name
result = await _post("/velxio/api/send-password-reset", params)
return bool(result and result.get("sent"))