From 6d233171087184fc60e07478a16735601f73c0a2 Mon Sep 17 00:00:00 2001 From: khoi Date: Mon, 17 Aug 2026 16:50:24 +0700 Subject: [PATCH] feat(mail): add mail configuration audit command --- AGENTS.md | 41 ++ README.md | 7 + odoo_db/db.py | 597 ++++++++++++++++++++++++++++- odoo_db/main.py | 231 +++++++++++ odoo_db/output.py | 11 +- site-docs/docs/cli-reference.md | 40 ++ tests/test_smoke.py | 653 ++++++++++++++++++++++++++++++++ 7 files changed, 1576 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index efd40a1..d5103a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,6 +187,47 @@ dump won't tell you): (`secret`, `password`, `token`, `api_key`, `dsn`, ...) in `odoo_db/db.py`. The global `--include-sensitive-information` reveals values (no per-command flag). Masking applies to `text` and `json`; `prometheus` emits only a count. +- `mail` audits outbound mail config (`get_mail_audit`). Ports a script that + checked the same data via the Odoo API (`odooly`) to direct SQL — none of + it needs auth. Dict keys are unordered; CLI `text` leads with + `config_parameters`, odoo-activity's TUI (`panes/mail.py`) instead leads + with `mail_servers` (see that repo's AGENTS.md/README). Top-level: 3 bool + flags — `is_neutralized` (`get_is_neutralized`), + `is_legacy_mail_config_configured` (`_is_legacy_mail_config_configured`: + was any of the 4 pre-v17 ICP mail keys ever set — permanently true once + it has been, migrated or not) and `is_alias_domain_migration_pending` + (`_is_alias_domain_migration_pending`: of those, is the leftover config + still live — true only when a legacy key is set **and** a company still + has no alias domain; this is the one that separates "migrated fine" from + "still stuck") — plus 5 sections: `config_parameters` + (`get_mail_config_parameters`), `alias_domains` + (`get_mail_alias_domains`, Odoo 17+ only, `None` pre-17), `addresses` + (`get_mail_addresses`), `mail_servers` (`get_mail_servers`, `ir.mail_server` + ordered by `sequence`, each row flagged `is_test_catcher`/ + `known_production_relay`/`is_neutralization_stub` — see + `_is_test_mail_catcher`/`_known_production_relay`/ + `_is_neutralization_stub_mail_server`), `modules` + (`get_mail_relevant_modules`, currently just `mass_mailing`). + + `mail_servers[].smtp_user`/`smtp_pass` are masked (`_SECRET_MASK`) like any + other secret; `--include-sensitive-information` reveals both. + `addresses` (company/OdooBot/admin) are organizational mailboxes, not + individual PII, and are deliberately never masked — see `get_mail_addresses`. + + CLI `text` output (not `get_mail_audit()` itself, which always returns the + full 6-key `config_parameters` list) drops the 4 legacy ICP keys once + `is_alias_domain_migration_pending` is `False` — see + `_relevant_mail_config_parameters`, called from `main.py`. `json` never + filters, so the two formats diverge on those 4 keys by design (see that + function's docstring for why: cross-version JSON diffing needs the full + list). + + Rationale for individual checks (upstream commit hashes for + `smtp_authentication`/`from_filter`, the neutralization stub row, the + test-catcher marker/host lists incl. the `papercut` exclusion and the + Mailtrap sandbox-vs-live split, the known-production-relay table, the + demo-data default addresses) lives in the corresponding function + docstrings/comments in `db.py`, not here. - `attachments` audits `ir.attachment` storage in pure SQL — no ORM, so it sees field-backed rows (`image_1920`, logos, signatures) natively. The ORM's `_search` auto-injects `res_field = False` and hides them; raw SQL has no diff --git a/README.md b/README.md index 96eee45..50c037e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ odoo-db [OPTIONS] COMMAND [DB] | `crons ` | List active scheduled actions (`--all` also includes inactive ones). `--running` shows crons currently held by an Odoo worker (RowShareLock on `ir_cron`) — transient debug data, not bundled into `prepare-audit`. `--include-code` adds the python source of each `state='code'` cron (ignored with `--running`, which already always shows it) | | `jobs ` | Queue job counts by state (requires `queue_job` module) | | `params [pattern]` | Show `ir_config_parameter` keys and values. Optional `pattern` narrows to keys containing it (case-insensitive substring). Values of secret-bearing keys are masked `********` by default; the global `--include-sensitive-information` reveals them | +| `mail ` | Audit outbound mail configuration: whether the database is neutralized (`database.is_neutralized`, the single most common reason mail never leaves an Odoo database — flagged up front, with Odoo's own inserted stub relay named in its own summary line rather than mistaken for a real one); the `ir_config_parameter` keys mail cares about (`mail.bounce.alias`, `mail.catchall.alias`/`.domain`, `mail.default.from`, plus Trobz's `default_email` and `mail.default.from_filter`); on Odoo 17+, the per-company `mail.alias.domain` records that actually control bounce/catchall/default-from routing now (the first 4 of those ICP keys became legacy in v17 and are hidden from the CLI's text output once they can no longer affect routing — shown side by side with a note otherwise, on a pre-17 database where they're the only mechanism or a 17+ one where a company still has no alias domain assigned while a legacy key still holds a value; Odoo never clears those keys even on a database that migrated fine, so a leftover value alone doesn't mean the migration is stuck — the JSON output always keeps the full list, since this tool's main job is comparing it across a v16-to-v19 migration; `mail.default.from_filter` stays live at runtime and is not part of that migration); company/system(OdooBot)/admin partner emails, resolved via `ir_model_data` so a renamed admin login or a deleted record still shows up (as `(record missing)`) instead of silently vanishing — shown as-is (organizational mailboxes, not individual PII), flagged if still at Odoo default (case-insensitively); outgoing `ir.mail_server` relays, named in a summary line (not a per-row column) when the name/host matches a known test-mail catcher like mailhog or a well-known managed relay like Google Workspace or Microsoft 365 — a positive confirmation, not just the absence of the other flag; and `mass_mailing` install state. Ported from an odooly/API-based check to direct SQL — none of it needs auth. SMTP username/password are masked by default; the global `--include-sensitive-information` reveals them | | `users ` | List active users with connection status | | `groups ` | List `res.groups` (category, name, share flag). `--include-users` adds each group's member logins. `--include-acls` adds per-group model access rights and record rules, plus top-level `global_acls`/`global_rules` for rows with no group at all (apply to every user — excluded from prior output, now the highest-value rows in a permission audit) | | `roles ` | List `res.users.role` (requires OCA `base_user_role`; prints a message if not installed). `--include-users` adds currently-enabled assigned users' logins. `--include-groups` adds the role's full resolved group set (its own group plus all directly and transitively implied groups) | @@ -88,6 +89,12 @@ odoo-db params my_db mail # Reveal masked secret values (database.secret, api keys, ...) odoo-db --include-sensitive-information params my_db +# Audit outbound mail configuration (config keys, key addresses, relays, mass_mailing) +odoo-db mail my_db + +# Same, revealing masked addresses and SMTP passwords +odoo-db --include-sensitive-information mail my_db + # List access groups, with members and ACLs odoo-db groups my_db --include-users --include-acls diff --git a/odoo_db/db.py b/odoo_db/db.py index 4509be5..d2bb676 100644 --- a/odoo_db/db.py +++ b/odoo_db/db.py @@ -125,6 +125,17 @@ def _is_odoo(cur: psycopg.Cursor) -> bool: return bool(cur.fetchone()) +def get_is_neutralized(cur: psycopg.Cursor) -> bool: + """`base/data/neutralize.sql` (identical 16.0-19.0, absent on 14.0) sets + `database.is_neutralized` via a plain SQL `VALUES (..., true)` — Postgres + stores that as the lowercase text `'true'`, not `'True'`, so the check + must be case-folded (a `row[0] == "True"` comparison here would silently + report every neutralized database as not neutralized).""" + cur.execute("SELECT value FROM ir_config_parameter WHERE key = 'database.is_neutralized'") + row = cur.fetchone() + return bool(row) and str(row[0]).strip().lower() in ("true", "t", "1") + + def list_databases(cur: psycopg.Cursor) -> list[str]: cur.execute(""" SELECT datname FROM pg_database @@ -158,9 +169,7 @@ def get_db_summary(dbname: str, verbose: bool = False) -> DbSummary | None: parts = row[0].split(".") version = ".".join(parts[:2]) if len(parts) >= 2 else row[0] - cur.execute("SELECT value FROM ir_config_parameter WHERE key='database.is_neutralized'") - row = cur.fetchone() - neutralized = row is not None and row[0] == "True" + neutralized = get_is_neutralized(cur) module_count = user_count = None if verbose: @@ -2143,6 +2152,588 @@ def get_company_count(cur: psycopg.Cursor) -> int: return _fetch_one(cur)[0] +# --------------------------------------------------------------------------- +# mail +# --------------------------------------------------------------------------- + +# (key, explanation) — the ir_config_parameter keys a mail-config audit cares +# about: who mail claims to be from, and where bounces/catch-alls land. +# `default_email` is Trobz-specific (read by trobz_base), not an Odoo core +# key, kept alongside the others since it answers the same question. +_MAIL_CONFIG_KEYS: tuple[tuple[str, str], ...] = ( + ("mail.bounce.alias", ""), + ("mail.catchall.alias", ""), + ("mail.catchall.domain", ""), + ("default_email", "Trobz-specific, used by trobz_base"), + ("mail.default.from", ""), + ("mail.default.from_filter", ""), +) + + +def get_mail_config_parameters(cur: psycopg.Cursor) -> list[dict]: + """``ir_config_parameter`` values relevant to outbound mail identity/bounces. + + None of ``_MAIL_CONFIG_KEYS`` match ``_is_sensitive_key`` so values are + never masked here. Keys not set in the database still get a row with + ``value: None``, matching the original API-based check's "(not defined)" + — via ``dict.get``, which only falls through to that default when the + key is genuinely absent. A key that exists with an empty-string value + (distinct from absent — Odoo's own ``get_param`` treats them + differently too) is preserved as ``""``, not coerced to ``None``; the + caller (``main.py``) must check ``is None`` rather than falsy-test the + value, or it collapses "never configured" and "configured blank" into + the same "(not defined)" display (verified against real data: on a + v16 staging database of ours, ``mail.catchall.domain``'s row exists + with value ``""``). + """ + keys = [k for k, _ in _MAIL_CONFIG_KEYS] + cur.execute("SELECT key, value FROM ir_config_parameter WHERE key = ANY(%s)", (keys,)) + values = dict(cur.fetchall()) + return [{"key": k, "explanation": explanation, "value": values.get(k)} for k, explanation in _MAIL_CONFIG_KEYS] + + +# The 4 legacy ICP keys mail.alias_domain._migrate_icp_to_domain() actually +# reads (mail/models/mail_alias_domain.py) -- deliberately narrower than +# _MAIL_CONFIG_KEYS, which also includes Trobz's own default_email and +# mail.default.from_filter (never read by that migration). +_LEGACY_ALIAS_MIGRATION_KEYS = frozenset({ + "mail.catchall.domain", + "mail.bounce.alias", + "mail.catchall.alias", + "mail.default.from", +}) + + +def _is_legacy_mail_config_configured(config_parameters: list[dict]) -> bool: + """Whether any of the pre-v17 ICP mail keys was ever actually set. + + True for *any* database that went through a v16-style config at some + point, not just a stuck one: ``_migrate_icp_to_domain`` reads these 4 + keys but never clears them (``mail/models/mail_alias_domain.py``: it + only ``get_param``s them and creates a record), so a leftover value is + the permanent state of a *successfully* migrated database too. On its + own this does not separate "migrated fine" from "migration still + pending" — see ``_is_alias_domain_migration_pending`` for that. Still + useful by itself for the gauge this was originally added for: without + it, ``odoo_db_mail_companies_missing_alias_domain`` reads 1 on + essentially every stock 17+ install and can't be alerted on — the + exact "flags nearly everything" trap this file already documents for + role-drift's ``extra_groups``. + """ + return any(p["value"] for p in config_parameters if p["key"] in _LEGACY_ALIAS_MIGRATION_KEYS) + + +def _is_alias_domain_migration_pending(alias_domains: list[dict] | None, *, legacy_configured: bool) -> bool: + """Whether the pre-17 ICP mail config still has an effect left to have. + + ``_migrate_icp_to_domain`` reads the 4 legacy keys but never clears + them, so a leftover value is the permanent state of any *successfully* + migrated database, not evidence of a stuck one — verified across real + databases: one production v17 with the ICP keys still set has every + company with an alias domain (migrated fine), distinct from a v16 + database with the same keys set and no ``mail_alias_domain`` table at + all (pre-17, not migrated yet). What actually separates "still + pending" from "done" is whether a company still has no alias domain: + only then can those leftover keys still be picked up by a (re-)run of + the migration. + """ + if alias_domains is None or not legacy_configured: + return False + return any(a["alias_domain_id"] is None for a in alias_domains) + + +def _relevant_mail_config_parameters( + config_parameters: list[dict], *, alias_domains: list[dict] | None, migration_pending: bool +) -> list[dict]: + """Drop the 4 legacy ICP keys when they can no longer affect routing: + Odoo 17+ (``alias_domains`` is not ``None``) and no migration left to + run (see ``_is_alias_domain_migration_pending``). Showing them right + next to the authoritative ``alias_domains`` section reads as if they + were still part of the active config — the opposite of helpful for a + reader debugging mail on a modern, working database. Kept whenever + they ARE relevant: pre-17 (no ``mail_alias_domain`` table at all, so + these are the only mechanism) or an unfinished v16-to-17 upgrade (a + company with no alias domain, where they can still be read). + """ + if alias_domains is not None and not migration_pending: + return [p for p in config_parameters if p["key"] not in _LEGACY_ALIAS_MIGRATION_KEYS] + return config_parameters + + +# Default addresses Odoo (or its demo data) ships with — an audit flags +# these as "still Odoo default". Sourced from the same check this command +# replaces (odooly-based, run against real client instances), plus +# "info@yourcompany.com" added after verifying against a v18 source tree +# (odoo/addons/base/data/res_users_demo.xml) — that's the value demo data +# actually writes to the main company partner; ".example.com" never appears +# there, so a demo-seeded DB would otherwise never trip this flag. +_MAIL_DEFAULT_COMPANY_EMAILS = frozenset({"info@yourcompany.example.com", "info@yourcompany.com"}) +_MAIL_DEFAULT_SYSTEM_EMAILS = frozenset({ + "root@yourcompany.example.com", # demo data + "root@example.com", + "odoobot@example.com", # ships in base/data/res_partner_data.xml since 16.0 (verified: a v16 + # database with mass_mailing uninstalled still carries it) +}) +_MAIL_DEFAULT_ADMIN_EMAILS = frozenset({"admin@yourcompany.example.com", "admin@example.com"}) + + +def _resolve_xmlid(cur: psycopg.Cursor, module: str, name: str) -> int | None: + """``res_id`` of a well-known singleton via its external id (``base.main_partner``, + ``base.user_root``, ``base.user_admin``, ...) — resolves correctly + regardless of a renamed login or a non-default row id, unlike matching + on ``login = 'admin'`` or a hardcoded ``res_partner`` id (see + ``get_mail_addresses``: both silently drop the row entirely on a + database where the login had been renamed — the normal state on + odoo.sh — or the row had been deleted, rather than reporting that the + lookup came up empty). + """ + cur.execute("SELECT res_id FROM ir_model_data WHERE module = %s AND name = %s", (module, name)) + row = cur.fetchone() + return row[0] if row else None + + +def _mail_address_row(partner_id: int | None, label: str, email: str | None, defaults: frozenset[str]) -> dict: + # Case-folded: Odoo never normalizes res.partner.email, so an untouched + # demo address typed back with capitals (e.g. ADMIN@Yourcompany.example.com) + # would otherwise pass the audit. + return { + "partner_id": partner_id, + "label": label, + "email": email, + "is_default": (email or "").strip().lower() in defaults, + # partner_id is None exactly when the xmlid didn't resolve at all, + # or resolved to a row that's since been deleted — distinct from a + # record that exists with no email set (email: None, missing: + # False). Matching on login or a hardcoded company id instead + # would either silently drop the row (no admin/system line at all, + # read as "no admin problem") or, for the hardcoded company id, + # report {"partner_id": 1, "email": None} for a partner that had + # been deleted outright — indistinguishable from "exists, blank + # email". + "missing": partner_id is None, + } + + +def get_mail_addresses(cur: psycopg.Cursor) -> list[dict]: + """Company/system/admin partner emails, flagged if still Odoo default (never customized). + + Mirrors the addresses a mail-config audit script checked via the ORM API + (the main company partner, the OdooBot user, the admin user) — ported to + direct SQL since none of it needs auth. Unlike the ORM, raw SQL has no + implicit ``active=True`` filter, so OdooBot (archived by design) is found + without the API script's explicit ``active=False`` domain, and an + archived admin/company record is included too rather than silently + disappearing. + + All three are resolved through ``ir_model_data`` (``base.main_partner``/ + ``base.user_root``/``base.user_admin``, see ``_resolve_xmlid``) rather + than a hardcoded ``res_partner`` id or ``login = 'admin'``/``'__system__'``: + matching on login silently drops the row entirely once that login has + been renamed (the normal state on odoo.sh, where the admin account + routinely gets a real customer email as its login), and a hardcoded + ``res_partner`` id 1 can't tell "deleted" from "exists with no email". + A row that can't be resolved this way — the xmlid is missing, or points + at a row that's since been deleted — is still emitted, with + ``missing: True`` and ``email: None`` (never silently dropped: "not + listed" must not read as "no admin problem"). + + Not masked, unlike ``ir_mail_server.smtp_pass``: these are organizational + mailboxes (company contact, the OdooBot service account, the admin + account), not individual end-user PII — usually already public (e.g. a + company's own contact address). Masking them would be friction over + data that isn't sensitive. + """ + results: list[dict] = [] + + company_id = _resolve_xmlid(cur, "base", "main_partner") + email = None + if company_id is not None: + cur.execute("SELECT email FROM res_partner WHERE id = %s", (company_id,)) + row = cur.fetchone() + if row is None: + company_id = None # xmlid resolved but the partner row is gone -> missing, not "no email" + else: + email = row[0] + results.append(_mail_address_row(company_id, "Company Email", email, _MAIL_DEFAULT_COMPANY_EMAILS)) + + for xmlid_name, label, defaults in ( + ("user_root", "System (OdooBot) Email", _MAIL_DEFAULT_SYSTEM_EMAILS), + ("user_admin", "Admin Email", _MAIL_DEFAULT_ADMIN_EMAILS), + ): + user_id = _resolve_xmlid(cur, "base", xmlid_name) + partner_id = user_email = None + if user_id is not None: + cur.execute( + """ + SELECT ru.partner_id, rp.email + FROM res_users ru + JOIN res_partner rp ON rp.id = ru.partner_id + WHERE ru.id = %s + """, + (user_id,), + ) + row = cur.fetchone() + if row is not None: + partner_id, user_email = row + results.append(_mail_address_row(partner_id, label, user_email, defaults)) + + return results + + +def _mail_server_select_sql(cur: psycopg.Cursor) -> str: + """Extra SELECT columns for ``ir_mail_server``, version-branched. + + ``smtp_authentication`` (login/certificate/cli) and ``from_filter`` + (per-server FROM allowlist) both landed in 15.0 — upstream commits + ``a4d513034ea8`` and ``1b9dd118cb0f``, neither reachable from 14.0 and + both reachable from 15.0, so one probe safely covers both. NULL on + 14.0 and earlier so every call site unpacks the same 10 columns + regardless of version. Probed the same to_regclass/pg_attribute way as + ``_groups_category_sql`` — avoids information_schema's bare-name + matching across every schema on the search_path. + """ + cur.execute(""" + SELECT 1 FROM pg_attribute + WHERE attrelid = to_regclass('ir_mail_server') AND attname = 'smtp_authentication' AND NOT attisdropped + """) + if cur.fetchone(): + return "smtp_authentication, from_filter" + return "NULL, NULL" + + +# Well-known SMTP test catchers. A server named/hosted after one of these +# accepts mail but never relays it anywhere real (by design, so a staging/dev +# environment can't leak test traffic to real inboxes), which looks identical +# to a working relay in both the raw data and Odoo's own UI (verified live: a +# real test send through mailhog landed in its own catch-all, `state='sent'` +# in Odoo with no error). +# +# A match is a hint to verify, not proof: these are product names, and +# `name` is free text an admin typed. Ambiguous markers are left out rather +# than guessed at — `papercut` matches Papercut-SMTP (a real catcher) but +# also PaperCut MF/NG, widely-deployed print-management software; telling an +# auditor a working relay is a dead end is worse than missing a catcher. +_TEST_MAIL_CATCHER_MARKERS = frozenset({ + "mailhog", + "mailcatcher", + "maildev", + "mailpit", + "smtp4dev", + "inbucket", + "mailslurp", +}) + +# Hosts that identify a catcher on their own, where a name/token marker +# can't: Mailtrap runs a sandbox (a catcher) and a real sending service on +# neighbouring hostnames — sandbox.smtp.mailtrap.io vs live.smtp.mailtrap.io +# — so "mailtrap" as a substring/token marker would flag real production +# traffic too. +_TEST_MAIL_CATCHER_HOSTS = ("sandbox.smtp.mailtrap.io", "ethereal.email") + + +def _is_test_mail_catcher(name: str | None, host: str | None) -> bool: + """Whole-token match against `name`/`smtp_host`, case-insensitive. + + Tokens, not substrings: a plain `in` check on the old marker list + flagged `maildevices.com` as `maildev`. Separators stay loose (any + non-alphanumeric run splits a token), so `mailhog-acme18-staging` + still matches on the `mailhog` token. + """ + tokens = set(re.split(r"[^a-z0-9]+", f"{name or ''} {host or ''}".lower())) + if tokens & _TEST_MAIL_CATCHER_MARKERS: + return True + lowered_host = (host or "").lower() + return any(known in lowered_host for known in _TEST_MAIL_CATCHER_HOSTS) + + +# The positive counterpart to _TEST_MAIL_CATCHER_MARKERS/_HOSTS: (label, +# exact hosts, suffixes, allowed ports) for well-known managed relays, so a +# match is a real production signal rather than just "not flagged as a test +# catcher" (an absence, not a confirmation). `ports=None` means the host +# alone is already distinctive enough (a dedicated per-provider hostname, +# unlike a generic SMTP port reused everywhere). +# +# Data, not predicates: every entry here is either an exact-host or a +# suffix check, so a lookup table expresses it directly and adding a +# provider is a one-line change. +_KNOWN_PRODUCTION_RELAYS: tuple[tuple[str, frozenset[str], tuple[str, ...], frozenset[int] | None], ...] = ( + # Google documents ports 25, 465 and 587 for both hosts (an earlier + # version of this table required 587/465 respectively — wrong, missed + # smtp.gmail.com:587 with STARTTLS, the most common Odoo setup on Gmail). + ("Google Workspace SMTP relay", frozenset({"smtp-relay.gmail.com"}), (), frozenset({25, 465, 587})), + ("Gmail SMTP", frozenset({"smtp.gmail.com"}), (), frozenset({25, 465, 587})), + # Per-tenant subdomain (direct send / relay connector), documented port 25. + ("Microsoft 365", frozenset(), (".mail.protection.outlook.com",), frozenset({25})), + # SMTP AUTH client submission: the usual Odoo-on-M365 setup, since it needs + # no Exchange-side connector, just a mailbox user/password mapped onto + # smtp_user/smtp_pass. Different hosts, so a separate entry rather than + # widening the one above — loosening its ports to None would drop the + # port-25 constraint that keeps the per-tenant suffix match honest. + ("Microsoft 365", frozenset({"smtp.office365.com", "smtp-mail.outlook.com"}), (), None), + # Brevo (renamed from Sendinblue); the old host still resolves. + ("Brevo (ex-Sendinblue)", frozenset({"smtp-relay.brevo.com", "smtp-relay.sendinblue.com"}), (), None), + ("Mandrill", frozenset({"smtp.mandrillapp.com"}), (), None), + ("OVH", frozenset({"ssl0.ovh.net"}), (), None), + ("Mailjet", frozenset(), (".mailjet.com",), None), + ("SendGrid", frozenset({"smtp.sendgrid.net"}), (), None), + ("Mailgun", frozenset({"smtp.mailgun.org", "smtp.eu.mailgun.org"}), (), None), + ("Postmark", frozenset({"smtp.postmarkapp.com"}), (), None), +) + +# email-smtp..amazonaws.com — a pattern, not a fixed host, so it +# needs its own check rather than a table row; anchored so an unrelated +# *.amazonaws.com host isn't reported as SES. +_AWS_SES_HOST_RE = re.compile(r"^email-smtp\.[a-z0-9-]+\.amazonaws\.com$") + + +def _known_production_relay(host: str | None, port: int | None) -> str | None: + """Label for a well-known managed relay, or `None`. + + `None` means "not recognised", never "not a real relay" — this is a + positive-confirmation signal, not the inverse of `is_test_catcher`. + Suffix checks require a leading `.` — a bare + `endswith("mailjet.com")`/`endswith("mail.protection.outlook.com")` + would match lookalike domains like `notmailjet.com` and + `evilmail.protection.outlook.com`. + """ + lowered = (host or "").strip().lower().rstrip(".") + if not lowered: + return None + if _AWS_SES_HOST_RE.match(lowered): + return "Amazon SES" + for label, exact_hosts, suffixes, ports in _KNOWN_PRODUCTION_RELAYS: + if lowered not in exact_hosts and not any(lowered.endswith(suffix) for suffix in suffixes): + continue + if ports is None or port in ports: + return label + return None + + +# base/data/neutralize.sql (identical 16.0-19.0, absent on 14.0) inserts +# exactly this row — name and host both hardcoded in Odoo core — after +# disabling every pre-existing relay (see get_is_neutralized). Caught in +# review: without this, a neutralized database (every odoo.sh staging +# build) shows the stub as an ordinary active relay and the real relay as +# inactive with no explanation, which reads exactly like a broken/disabled +# relay rather than an intentional neutralization side effect. +_NEUTRALIZATION_STUB_NAME = "neutralization - disable emails" +_NEUTRALIZATION_STUB_HOST = "invalid" + + +def _is_neutralization_stub_mail_server(name: str | None, host: str | None) -> bool: + return (name or "").strip().lower() == _NEUTRALIZATION_STUB_NAME and ( + host or "" + ).strip().lower() == _NEUTRALIZATION_STUB_HOST + + +def get_mail_servers(cur: psycopg.Cursor, *, reveal: bool = False) -> list[dict]: + """Outgoing SMTP relays (``ir.mail_server``), ordered by priority (sequence). + + ``smtp_user``/``smtp_pass`` are masked like any other secret + (``_SECRET_MASK``) unless ``reveal`` is set — the original script + printed both in cleartext, which this tool's existing secret-masking + convention (see ``get_config_parameters``) deliberately does not repeat + by default. Both, not just the password: the SMTP username is a + credential too (may itself be a real mailbox address), unlike the + organizational addresses in ``get_mail_addresses`` which aren't masked + at all. + + ``is_test_catcher`` (see ``_is_test_mail_catcher``) flags a row whose + name/host names a known test-mail catcher — the audit's answer to "why + doesn't this email arrive", without requiring the reader to already + know what that tool is. ``known_production_relay`` (see + ``_known_production_relay``) is its positive counterpart: a label when + host+port match a well-known managed relay (Google, Microsoft 365) — + a real confirmation signal, not just the absence of the other flag. + + ``is_neutralization_stub`` (see ``_is_neutralization_stub_mail_server``) + flags Odoo core's own db_neutralize placeholder row — the single most + common reason mail never leaves an Odoo database, and not a test + catcher at all (see ``get_is_neutralized`` for the accompanying + top-level flag). + """ + extra_sql = _mail_server_select_sql(cur) + # extra_sql is one of the two fixed literal strings returned by + # _mail_server_select_sql above — never user or row data, so the f-string + # is safe. ty wants a LiteralString, which a dynamically-built (but still + # internally-fixed) query string can never satisfy. + cur.execute(f""" + SELECT sequence, name, smtp_host, smtp_port, smtp_user, smtp_pass, smtp_encryption, active, {extra_sql} + FROM ir_mail_server + ORDER BY sequence, name + """) # noqa: S608 # ty: ignore[no-matching-overload] + rows: list[dict] = [] + for seq, name, host, port, user, pwd, encryption, active, authentication, from_filter in cur.fetchall(): + rows.append({ + "sequence": seq, + "name": name, + "smtp_host": host, + "smtp_port": port, + "smtp_user": user if (reveal or not user) else _SECRET_MASK, + "smtp_pass": pwd if (reveal or not pwd) else _SECRET_MASK, + "smtp_encryption": encryption, + "smtp_authentication": authentication, + "from_filter": from_filter, + "active": bool(active), + "is_test_catcher": _is_test_mail_catcher(name, host), + "known_production_relay": _known_production_relay(host, port), + "is_neutralization_stub": _is_neutralization_stub_mail_server(name, host), + }) + return rows + + +def get_mail_relevant_modules(cur: psycopg.Cursor) -> list[dict]: + """State of modules that materially change mail behavior (currently: ``mass_mailing``).""" + cur.execute( + "SELECT name, state FROM ir_module_module WHERE name = ANY(%s) ORDER BY name", + (["mass_mailing"],), + ) + return [{"name": r[0], "state": r[1]} for r in cur.fetchall()] + + +def _mail_alias_local_email(local_part: str | None, domain_name: str | None) -> str | None: + """Mirrors ``AliasDomain._compute_bounce_email``/``_compute_catchall_email``: always `local@domain`.""" + if not local_part or not domain_name: + return None + return f"{local_part}@{domain_name}" + + +def _mail_alias_default_from_email(default_from: str | None, domain_name: str | None) -> str | None: + """Mirrors ``AliasDomain._compute_default_from_email``: keep as-is if already a full address.""" + if not default_from: + return None + if "@" in default_from: + return default_from + if not domain_name: + return None + return f"{default_from}@{domain_name}" + + +def get_mail_alias_domains(cur: psycopg.Cursor) -> list[dict] | None: + """Per-company ``mail.alias.domain``, the *actual* runtime source (Odoo 17+) + for catchall/bounce/default-from — supersedes the ``mail.catchall.domain`` + / ``mail.bounce.alias`` / ``mail.catchall.alias`` / ``mail.default.from`` + ``ir_config_parameter`` keys in ``get_mail_config_parameters``. + + Odoo's own model docstring says it plainly: "This replaces + ``mail.alias.domain`` configuration parameter use until v16" + (``odoo/addons/mail/models/mail_alias_domain.py``). Past v16, those ICP + keys are read only by ``AliasDomain._migrate_icp_to_domain`` — a one-time + compatibility helper for installing ``mail`` after ``base`` was already + configured (e.g. the odoo.sh flow) — and are otherwise vestigial: a value + sitting there does not mean Odoo is actually using it to route bounces or + replies. Verified by grepping an Odoo 18 community + enterprise source + tree: no runtime code in ``mail`` reads ``mail.catchall.domain``/ + ``mail.bounce.alias``/``mail.catchall.alias`` by key outside that + migration helper — OCA or custom modules may still read + ``mail.catchall.domain`` directly, so this doesn't generalize past core. + + Returns ``None`` if ``mail_alias_domain`` doesn't exist (pre-17, or the + ``mail`` app not installed at all) — ``get_mail_config_parameters`` is + then the only signal available, same as pre-17 in reality. + + A company with ``alias_domain_id IS NULL`` has *no* alias domain assigned + — bounces/replies for that company aren't routed anywhere — but this is + **not** on its own a misconfiguration: it's also the documented state of + a clean 17+ install that never had v16-style ICP config to migrate + (verified across real v17/v18/v19 databases: 4 of 5 with ``mail`` + installed have zero ``mail_alias_domain`` rows, all clean installs). + Surfaced as ``alias_domain: None`` rather than silently omitted either + way; ``_is_alias_domain_migration_pending`` (see ``get_mail_audit``) is + what actually tells a clean or successfully-migrated install apart + from a genuinely stuck one — a leftover ICP value alone + (``_is_legacy_mail_config_configured``) does not, since + ``_migrate_icp_to_domain`` never clears those keys even when it + succeeds. + + Filters ``res_company.active = true`` — unlike ``get_mail_addresses`` + (where seeing past the ORM's implicit filter is the point), an archived + company isn't sending real mail, so counting it here would just add + false-positive noise to ``odoo_db_mail_companies_missing_alias_domain``. + Same rationale ``get_groups(include_users=True)`` uses for filtering + ``u.active = true`` on membership rows. + """ + cur.execute("SELECT to_regclass('public.mail_alias_domain')") + if not _fetch_one(cur)[0]: + return None + + cur.execute(""" + SELECT c.id, c.name, mad.id, mad.name, mad.bounce_alias, mad.catchall_alias, mad.default_from + FROM res_company c + LEFT JOIN mail_alias_domain mad ON mad.id = c.alias_domain_id + WHERE c.active = true + ORDER BY c.id + """) + rows: list[dict] = [] + for company_id, company_name, domain_id, domain_name, bounce_alias, catchall_alias, default_from in cur.fetchall(): + rows.append({ + "company_id": company_id, + "company_name": company_name, + "alias_domain_id": domain_id, + "alias_domain": domain_name, + "bounce_email": _mail_alias_local_email(bounce_alias, domain_name), + "catchall_email": _mail_alias_local_email(catchall_alias, domain_name), + "default_from_email": _mail_alias_default_from_email(default_from, domain_name), + }) + return rows + + +def get_mail_audit(cur: psycopg.Cursor, *, reveal: bool = False) -> dict: + """Audit bundle for outbound mail configuration. + + Ported from a script that gathered the same data through the ORM API + (odooly client) — none of it needs auth, so direct SQL replaces it, + picking up raw-SQL's usual side benefit of seeing past the ORM's implicit + ``active=True`` filter (see ``get_mail_addresses``). ``alias_domains`` + (Odoo 17+) is the authoritative counterpart to ``config_parameters`` — + see ``get_mail_alias_domains`` for why both are kept rather than one + replacing the other. ``reveal`` now only affects ``mail_servers`` + (``smtp_pass`` is a real credential) — ``addresses`` are organizational + mailboxes, not masked regardless (see ``get_mail_addresses``). + + ``is_neutralized`` (see ``get_is_neutralized``) is the single most + common reason mail never leaves an Odoo database — every odoo.sh + staging build looks like this — read the same way ``odoo-db list`` + already reads the same ``database.is_neutralized`` key. + + ``is_legacy_mail_config_configured``/``is_alias_domain_migration_pending`` + (see ``_is_legacy_mail_config_configured``/ + ``_is_alias_domain_migration_pending``) together let a caller tell a + clean 17+ install, a successfully migrated one, and a genuinely stuck + v16-to-17 upgrade apart — all three otherwise look identical as just + ``alias_domain_id IS NULL`` (the first two) or a leftover ICP value + (the last two) taken alone. + + ``config_parameters`` here is always the full 6-key list — unlike + ``odoo-db mail``'s own text output, which drops the 4 legacy ICP keys + once ``is_alias_domain_migration_pending`` says they're no longer + relevant (see ``_relevant_mail_config_parameters``, called from + ``main.py``, not here). Kept complete in this dict deliberately: this + is also what ``--output-format json`` returns, and this tool's main + job is v16-to-v19 migration audits — comparing that JSON across + versions, a leftover ``mail.catchall.domain`` present in one and + silently dropped from the other would be indistinguishable from + "never existed". Whether to hide an always-empty key is a + presentation call for a human reading text/TUI output, not something + the machine-readable artifact should also make. + """ + config_parameters = get_mail_config_parameters(cur) + alias_domains = get_mail_alias_domains(cur) + legacy_configured = _is_legacy_mail_config_configured(config_parameters) + migration_pending = _is_alias_domain_migration_pending(alias_domains, legacy_configured=legacy_configured) + + return { + "is_neutralized": get_is_neutralized(cur), + "config_parameters": config_parameters, + "is_legacy_mail_config_configured": legacy_configured, + "is_alias_domain_migration_pending": migration_pending, + "alias_domains": alias_domains, + "addresses": get_mail_addresses(cur), + "mail_servers": get_mail_servers(cur, reveal=reveal), + "modules": get_mail_relevant_modules(cur), + } + + # Per-row overhead used by the statistical bloat estimate. Deliberately # coarse — the estimate is a triage signal, not a measurement (run with # pgstattuple for exact numbers). Heap: 23-byte HeapTupleHeader rounded to 24 diff --git a/odoo_db/main.py b/odoo_db/main.py index 8be6660..042c88b 100644 --- a/odoo_db/main.py +++ b/odoo_db/main.py @@ -323,6 +323,237 @@ def params( ) +# --------------------------------------------------------------------------- +# mail +# --------------------------------------------------------------------------- + + +@app.command() +def mail(db_name: Annotated[str, typer.Argument(metavar="DB")]): + """Audit outbound mail configuration: config keys, alias domains, addresses, relays, mass_mailing. + + Ports a script that checked the same things through the Odoo API + (odooly) to direct SQL — none of this data needs auth. Company/system + (OdooBot)/admin email addresses are organizational mailboxes, not + individual PII, so they're shown as-is; `ir_mail_server.smtp_user`/ + `smtp_pass` are real credentials and stay masked — pass the global + --include-sensitive-information to reveal them. + + On Odoo 17+, four of the config parameters above — + `mail.catchall.domain`/`mail.bounce.alias`/`mail.catchall.alias`/ + `mail.default.from` — are legacy (read only by a one-time migration + helper) and shown alongside the per-company `mail.alias.domain` records + that actually control bounce/catchall/default-from routing now. + `mail.default.from_filter` is not part of that migration and stays + live at runtime on 17.0-19.0 (`IrMailServer._get_default_from_filter`). + + Flags a neutralized database (`database.is_neutralized`, set by + `base/data/neutralize.sql` on 16.0-19.0) up front — the single most + common reason mail never leaves an Odoo database — and marks the + stub relay it inserts (`is_neutralization_stub`) so it isn't mistaken + for a real, working server. + """ + with _handle_errors(db_name), db.cursor(db_name) as cur: + data = db.get_mail_audit(cur, reveal=_include_sensitive) + + with _writer() as w: + if w.fmt == "json": + w.json(data) + elif w.fmt == "prometheus": + # Gauges count active rows only, like every other command's: an + # archived relay relays nothing, so counting it moves alert + # thresholds without anything having changed (AGENTS.md: "gauges + # keep counting installed/active rows only, so alert thresholds + # don't move"). + default_count = sum(1 for a in data["addresses"] if a["is_default"]) + # The neutralization stub is active=true by design (Odoo inserts it + # that way) but isn't a real relay, so it's excluded here too — a + # neutralized database should read 0 active servers, not 1. + active_servers = sum(1 for m in data["mail_servers"] if m["active"] and not m["is_neutralization_stub"]) + test_catcher_count = sum(1 for m in data["mail_servers"] if m["is_test_catcher"] and m["active"]) + known_relay_count = sum(1 for m in data["mail_servers"] if m["known_production_relay"] and m["active"]) + lines = [ + "# HELP odoo_db_mail_neutralized Whether database.is_neutralized is set", + "# TYPE odoo_db_mail_neutralized gauge", + f'odoo_db_mail_neutralized{{db="{db_name}"}} {int(data["is_neutralized"])}', + "# HELP odoo_db_mail_servers Active outgoing SMTP relay count", + "# TYPE odoo_db_mail_servers gauge", + f'odoo_db_mail_servers{{db="{db_name}"}} {active_servers}', + "# HELP odoo_db_mail_default_addresses Company/system/admin addresses still at Odoo default", + "# TYPE odoo_db_mail_default_addresses gauge", + f'odoo_db_mail_default_addresses{{db="{db_name}"}} {default_count}', + "# HELP odoo_db_mail_test_catcher_servers Active outgoing relays that are known test mail catchers", + "# TYPE odoo_db_mail_test_catcher_servers gauge", + f'odoo_db_mail_test_catcher_servers{{db="{db_name}"}} {test_catcher_count}', + "# HELP odoo_db_mail_known_production_relay_servers " + "Active outgoing relays matching a known managed relay", + "# TYPE odoo_db_mail_known_production_relay_servers gauge", + f'odoo_db_mail_known_production_relay_servers{{db="{db_name}"}} {known_relay_count}', + ] + if data["alias_domains"] is not None: + # alias_domain_id IS NULL alone is the documented, expected + # state of a clean 17+ install with mail installed but never + # configured — not a misconfiguration, and true for most + # real databases (the same "flags nearly everything" trap + # AGENTS.md documents for role-drift's extra_groups). Only + # count companies at all when a migration is genuinely + # still pending -- a leftover legacy ICP value on its own + # is also the permanent state of a database that migrated + # fine, not evidence of anything broken. + missing = ( + sum(1 for a in data["alias_domains"] if a["alias_domain_id"] is None) + if data["is_alias_domain_migration_pending"] + else 0 + ) + lines += [ + "# HELP odoo_db_mail_companies_missing_alias_domain " + "Companies with no mail.alias.domain assigned despite legacy ICP mail config still set " + "(Odoo 17+, a stuck v16-to-17 migration)", + "# TYPE odoo_db_mail_companies_missing_alias_domain gauge", + f'odoo_db_mail_companies_missing_alias_domain{{db="{db_name}"}} {missing}', + ] + w.prometheus(lines) + else: + if data["is_neutralized"]: + w.text( + "DATABASE IS NEUTRALIZED (database.is_neutralized=true): outgoing mail is disabled by " + "an Odoo-inserted stub relay (see below) — any other relay listed as inactive was " + "disabled by neutralization, not misconfiguration.\n" + ) + + # Filtered for this text table only -- data["config_parameters"] + # itself (also what --output-format json returns) always keeps + # the full 6-key list, since a migration audit comparing that + # JSON across versions needs a leftover key to stay visible + # even once it's no longer relevant (see get_mail_audit). + relevant_config_parameters = db._relevant_mail_config_parameters( + data["config_parameters"], + alias_domains=data["alias_domains"], + migration_pending=data["is_alias_domain_migration_pending"], + ) + w.text("Config parameters:") + w.table( + ["key", "value"], + [ + [ + c["key"] + (f" ({c['explanation']})" if c["explanation"] else ""), + "(not defined)" if c["value"] is None else c["value"], + ] + for c in relevant_config_parameters + ], + ) + + if data["alias_domains"] is not None: + if data["is_alias_domain_migration_pending"]: + w.text( + "\nNote: since Odoo 17, mail.catchall.domain/mail.bounce.alias/mail.catchall.alias/" + "mail.default.from above are legacy (read only by a one-time migration helper) — Odoo " + "actually routes bounces/catchall/default-from per company via the alias domain below, " + "and at least one company here has none, so that migration hasn't finished." + ) + else: + w.text( + "\nNote: the pre-17 mail.catchall.domain/mail.bounce.alias/mail.catchall.alias/" + "mail.default.from keys aren't shown above: they can no longer affect routing on this " + "database. Odoo routes bounces/catchall/default-from per company via the alias domain " + "below." + ) + w.text("\nAlias domains (Odoo 17+, authoritative):") + w.table( + ["company", "alias_domain", "bounce_email", "catchall_email", "default_from_email"], + [ + [ + a["company_name"], + a["alias_domain"] + or ( + "NOT SET despite legacy ICP config still present — stuck v16-to-17 migration!" + if data["is_legacy_mail_config_configured"] + else "(not set — normal for a clean 17+ install)" + ), + a["bounce_email"] or "", + a["catchall_email"] or "", + a["default_from_email"] or "", + ] + for a in data["alias_domains"] + ], + ) + + w.text("\nRelevant addresses:") + rows = [] + for a in data["addresses"]: + if a["missing"]: + email = "(record missing)" + else: + email = "(not set)" if a["email"] is None else a["email"] + if a["is_default"]: + email += " [WARNING: still Odoo default — update it]" + partner_id = "" if a["partner_id"] is None else str(a["partner_id"]) + rows.append([partner_id, a["label"], email]) + w.table(["partner_id", "label", "email"], rows) + + w.text("\nOutgoing mail servers:") + if data["mail_servers"]: + # 7 columns, not 9: user+password are both masked by + # default, so between them they carried one bit ("is + # something set") at the cost of the two widest cells — + # host, the column an audit is actually for. encryption/auth are + # one concept, collapsed the same way. Full values + # (revealed or not) stay in the json output. + w.table( + ["seq", "name", "host:port", "creds", "encryption/auth", "from_filter", "active"], + [ + [ + str(m["sequence"]) if m["sequence"] is not None else "", + m["name"], + f"{m['smtp_host'] or ''}:{m['smtp_port'] if m['smtp_port'] is not None else ''}", + "/".join(filter(None, (m["smtp_user"], m["smtp_pass"]))) + if _include_sensitive + else ("set" if (m["smtp_user"] or m["smtp_pass"]) else "-"), + " / ".join(filter(None, (m["smtp_encryption"], m["smtp_authentication"]))), + m["from_filter"] or "", + "yes" if m["active"] else "no", + ] + for m in data["mail_servers"] + ], + fold=frozenset({"host:port", "creds"}), + ) + # Named, not "servers": with several relays on one database, + # a bare "[TEST CATCHER] servers accept mail..." forces the + # reader to guess which row it means. Archived (active=False) + # relays are excluded — they swallow + # nothing, so flagging one reads as a false alarm. Deduped + # (`sorted(set(...))`): two relays at the same known + # provider would otherwise repeat its name once per row. + catchers = sorted({m["name"] for m in data["mail_servers"] if m["is_test_catcher"] and m["active"]}) + if catchers: + w.text( + f" WARNING: {', '.join(catchers)} — test-mail catcher(s): accept mail but never relay " + "it anywhere real, so a real send never reaches a real inbox from here, by design." + ) + known_relays = sorted({ + m["known_production_relay"] + for m in data["mail_servers"] + if m["known_production_relay"] and m["active"] + }) + if known_relays: + w.text(f" [KNOWN RELAY] confirmed against: {', '.join(known_relays)} — a real managed relay.") + stubs = sorted({m["name"] for m in data["mail_servers"] if m["is_neutralization_stub"]}) + if stubs: + w.text( + f" [NEUTRALIZATION STUB] {', '.join(stubs)} — Odoo's own db_neutralize placeholder, " + "not a real relay, and not the cause of mail failing on its own." + ) + else: + w.text(" (none defined: odoo will use localhost:25)") + + w.text("\nRelevant modules:") + w.table( + ["module", "state"], + [[m["name"], m["state"]] for m in data["modules"]], + empty_msg=" (none of the tracked modules found)", + ) + + # --------------------------------------------------------------------------- # jobs # --------------------------------------------------------------------------- diff --git a/odoo_db/output.py b/odoo_db/output.py index 96e2149..bc24665 100644 --- a/odoo_db/output.py +++ b/odoo_db/output.py @@ -35,13 +35,22 @@ def table( rows: list[list[str]], empty_msg: str = "(no results)", footer: list[str] | None = None, + fold: frozenset[str] | None = None, ): if not rows: self._write(empty_msg) return t = Table(show_header=True, header_style="bold cyan", show_lines=True, show_footer=footer is not None) for i, h in enumerate(headers): - t.add_column(h, footer=("[bold]" + footer[i] + "[/bold]") if footer else "") + # `fold` wraps mid-token instead of eliding: for a column holding a + # single unbreakable token (a hostname, a credential), rich's default + # `ellipsis` silently drops characters as soon as the table is + # narrower than the content. + t.add_column( + h, + footer=("[bold]" + footer[i] + "[/bold]") if footer else "", + overflow="fold" if fold and h in fold else "ellipsis", + ) for row in rows: t.add_row(*row) console = Console(file=self._f) diff --git a/site-docs/docs/cli-reference.md b/site-docs/docs/cli-reference.md index 7c93af2..34eacfe 100644 --- a/site-docs/docs/cli-reference.md +++ b/site-docs/docs/cli-reference.md @@ -36,6 +36,7 @@ $ odoo-db [OPTIONS] COMMAND [ARGS]... * `modules`: List modules with version for a database. * `crons`: List scheduled actions for a database. * `params`: Show ir_config_parameter keys and values... +* `mail`: Audit outbound mail configuration: config... * `jobs`: List queue job counts by state for a... * `users`: List users for a database. * `groups`: List res.groups for a database. @@ -132,6 +133,45 @@ $ odoo-db params [OPTIONS] DB [PATTERN] * `--help`: Show this message and exit. +## `odoo-db mail` + +Audit outbound mail configuration: config keys, alias domains, addresses, relays, mass_mailing. + +Ports a script that checked the same things through the Odoo API +(odooly) to direct SQL — none of this data needs auth. Company/system +(OdooBot)/admin email addresses are organizational mailboxes, not +individual PII, so they're shown as-is; `ir_mail_server.smtp_user`/ +`smtp_pass` are real credentials and stay masked — pass the global +--include-sensitive-information to reveal them. + +On Odoo 17+, four of the config parameters above — +`mail.catchall.domain`/`mail.bounce.alias`/`mail.catchall.alias`/ +`mail.default.from` — are legacy (read only by a one-time migration +helper) and shown alongside the per-company `mail.alias.domain` records +that actually control bounce/catchall/default-from routing now. +`mail.default.from_filter` is not part of that migration and stays +live at runtime on 17.0-19.0 (`IrMailServer._get_default_from_filter`). + +Flags a neutralized database (`database.is_neutralized`, set by +`base/data/neutralize.sql` on 16.0-19.0) up front — the single most +common reason mail never leaves an Odoo database — and marks the +stub relay it inserts (`is_neutralization_stub`) so it isn't mistaken +for a real, working server. + +**Usage**: + +```console +$ odoo-db mail [OPTIONS] DB +``` + +**Arguments**: + +* `DB`: \[required\] + +**Options**: + +* `--help`: Show this message and exit. + ## `odoo-db jobs` List queue job counts by state for a database. diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 6aeb4e9..d31591b 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -5,13 +5,22 @@ from odoo_db.db import ( _bloat_estimate_pages, _groups_category_sql, + _is_alias_domain_migration_pending, + _is_legacy_mail_config_configured, + _is_neutralization_stub_mail_server, _localize, _mime_family, + _relevant_mail_config_parameters, _validate_attachment_orphans, compute_role_drift, filter_online_users, generate_password, get_config_parameters, + get_is_neutralized, + get_mail_addresses, + get_mail_alias_domains, + get_mail_config_parameters, + get_mail_servers, get_modules, get_users, ) @@ -218,6 +227,11 @@ def test_crons_help(): assert "--all" in result.output +def test_mail_help(): + result = runner.invoke(app, ["mail", "--help"]) + assert result.exit_code == 0 + + def test_dump_help(): result = runner.invoke(app, ["dump", "--help"]) assert result.exit_code == 0 @@ -291,6 +305,645 @@ def test_get_config_parameters_reveals_secrets_when_asked(): assert result == [{"key": "database.secret", "value": "a1b9f3e2c8d4"}] +def test_get_mail_config_parameters_fills_all_keys_including_unset(): + cur = _FakeParamsCursor([ + ("mail.catchall.domain", "example.com"), + ("default_email", "root@example.com"), + ("mail.catchall.alias", ""), # row exists but blank -> distinct from absent + ]) + result = get_mail_config_parameters(cur) # ty: ignore[invalid-argument-type] + + assert [r["key"] for r in result] == [ + "mail.bounce.alias", + "mail.catchall.alias", + "mail.catchall.domain", + "default_email", + "mail.default.from", + "mail.default.from_filter", + ] + by_key = {r["key"]: r["value"] for r in result} + assert by_key["mail.catchall.domain"] == "example.com" + assert by_key["default_email"] == "root@example.com" + # Absent key -> None ("(not defined)" at display time). + assert by_key["mail.bounce.alias"] is None + # Present-but-blank key -> "" preserved, NOT coerced to None — main.py's + # display must check `is None` rather than falsy-test this, or it shows + # "(not defined)" for a row that actually exists with an empty value + # (real case: on a v16 staging database of ours, mail.catchall.domain). + assert by_key["mail.catchall.alias"] == "" + + +def test_is_legacy_mail_config_configured_false_on_a_clean_17_plus_install(): + # The documented install path: mail installed, ICP keys never set, + # alias_domain_id never populated because there was nothing to migrate + # -- not a misconfiguration (verified across 4 of 5 real v17/v18/v19 + # databases with mail installed). + config_parameters = [ + {"key": "mail.bounce.alias", "explanation": "", "value": None}, + {"key": "mail.catchall.alias", "explanation": "", "value": None}, + {"key": "mail.catchall.domain", "explanation": "", "value": None}, + {"key": "default_email", "explanation": "", "value": None}, + {"key": "mail.default.from", "explanation": "", "value": None}, + {"key": "mail.default.from_filter", "explanation": "", "value": None}, + ] + assert _is_legacy_mail_config_configured(config_parameters) is False + + +def test_is_legacy_mail_config_configured_true_when_a_legacy_icp_key_survives(): + # A stuck v16-to-17 upgrade: mail.catchall.domain was configured before + # the upgrade and the alias_domain migration never ran (or failed) -- + # this is the case actually worth alerting on. + config_parameters = [ + {"key": "mail.bounce.alias", "explanation": "", "value": None}, + {"key": "mail.catchall.alias", "explanation": "", "value": None}, + {"key": "mail.catchall.domain", "explanation": "", "value": "example.com"}, + {"key": "default_email", "explanation": "", "value": None}, + {"key": "mail.default.from", "explanation": "", "value": None}, + {"key": "mail.default.from_filter", "explanation": "", "value": None}, + ] + assert _is_legacy_mail_config_configured(config_parameters) is True + + +def test_is_legacy_mail_config_configured_ignores_non_migration_keys(): + # default_email (Trobz-specific) and mail.default.from_filter aren't + # read by _migrate_icp_to_domain -- a value there says nothing about + # whether an alias-domain migration is stuck. + config_parameters = [ + {"key": "default_email", "explanation": "", "value": "root@example.com"}, + {"key": "mail.default.from_filter", "explanation": "", "value": "example.com"}, + {"key": "mail.catchall.domain", "explanation": "", "value": None}, + ] + assert _is_legacy_mail_config_configured(config_parameters) is False + + +def test_is_legacy_mail_config_configured_treats_blank_value_as_not_configured(): + # A row that exists but is blank ("" — see the present-but-blank case + # above) is not a leftover legacy value worth alerting on. + config_parameters = [{"key": "mail.catchall.domain", "explanation": "", "value": ""}] + assert _is_legacy_mail_config_configured(config_parameters) is False + + +_ALL_SIX_CONFIG_PARAMETERS = [ + {"key": "mail.bounce.alias", "explanation": "", "value": None}, + {"key": "mail.catchall.alias", "explanation": "", "value": None}, + {"key": "mail.catchall.domain", "explanation": "", "value": None}, + {"key": "default_email", "explanation": "Trobz-specific, used by trobz_base", "value": None}, + {"key": "mail.default.from", "explanation": "", "value": None}, + {"key": "mail.default.from_filter", "explanation": "", "value": None}, +] + + +def test_relevant_mail_config_parameters_hides_legacy_keys_when_migration_is_not_pending(): + # Showing 4 always-empty legacy keys right next to the authoritative + # alias_domains section reads as if they're part of the active config, + # confusing a reader debugging mail on a modern, working database -- + # true both for a clean install and for one that migrated fine. + result = _relevant_mail_config_parameters(_ALL_SIX_CONFIG_PARAMETERS, alias_domains=[], migration_pending=False) + assert [r["key"] for r in result] == ["default_email", "mail.default.from_filter"] + + +def test_relevant_mail_config_parameters_keeps_legacy_keys_pre_17(): + # alias_domains is None -> the legacy ICP keys are the only mechanism, + # so they're always relevant regardless of migration_pending. + result = _relevant_mail_config_parameters(_ALL_SIX_CONFIG_PARAMETERS, alias_domains=None, migration_pending=False) + assert len(result) == 6 + + +def test_relevant_mail_config_parameters_keeps_legacy_keys_when_migration_is_pending(): + # alias_domains exists (17+) and the migration is still pending (a + # company with no alias domain, while a legacy key still holds a + # value) -- the diagnostic evidence for a stuck v16-to-17 upgrade, + # must stay visible. + result = _relevant_mail_config_parameters(_ALL_SIX_CONFIG_PARAMETERS, alias_domains=[], migration_pending=True) + assert len(result) == 6 + + +def test_is_alias_domain_migration_pending_false_when_alias_domains_is_none(): + # Pre-17 (or mail not installed) -- the ICP keys are the only + # mechanism there, not a migration in progress. + assert _is_alias_domain_migration_pending(None, legacy_configured=True) is False + + +def test_is_alias_domain_migration_pending_false_when_no_legacy_config(): + # A clean 17+ install: nothing was ever migrated, so nothing can be + # "pending" regardless of what alias_domains looks like. + alias_domains = [{"company_id": 1, "alias_domain_id": None}] + assert _is_alias_domain_migration_pending(alias_domains, legacy_configured=False) is False + + +def test_is_alias_domain_migration_pending_false_when_migration_succeeded(): + # A real-world case: a legacy ICP key still holds a value (Odoo never + # clears it), but every company already has an alias domain -- the + # migration ran and finished, this is not "stuck". + alias_domains = [ + {"company_id": 1, "alias_domain_id": 10}, + {"company_id": 2, "alias_domain_id": 11}, + ] + assert _is_alias_domain_migration_pending(alias_domains, legacy_configured=True) is False + + +def test_is_alias_domain_migration_pending_true_when_a_company_still_has_none(): + alias_domains = [ + {"company_id": 1, "alias_domain_id": 10}, + {"company_id": 2, "alias_domain_id": None}, + ] + assert _is_alias_domain_migration_pending(alias_domains, legacy_configured=True) is True + + +class _FakeOneCursor: + """Cursor stand-in returning one queued fetchone() result per execute().""" + + def __init__(self, *rows): + self._rows = list(rows) + self._current = None + + def execute(self, query, params=None): + self._current = self._rows.pop(0) + + def fetchone(self): + return self._current + + +class _FakeXmlidCursor: + """Cursor stand-in for get_mail_addresses: resolves ir_model_data + lookups from a dict keyed by (module, name), and detail (email) lookups + from a queue -- since unlike _FakeOneCursor's fixed call order, which + query runs next depends on whether the previous xmlid resolved.""" + + def __init__(self, xmlids: dict[tuple[str, str], int], detail_answers: list): + self._xmlids = xmlids + self._detail_answers = list(detail_answers) + self._current = None + + def execute(self, query, params: tuple[str, str] | None = None): + if "ir_model_data" in query: + res_id = self._xmlids.get(params) if params is not None else None + self._current = (res_id,) if res_id is not None else None + else: + self._current = self._detail_answers.pop(0) + + def fetchone(self): + return self._current + + +def test_get_mail_addresses_shows_real_email_and_flags_defaults(): + # Organizational mailboxes, not individual PII -> shown as-is, not masked. + cur = _FakeXmlidCursor( + xmlids={("base", "main_partner"): 1, ("base", "user_root"): 17, ("base", "user_admin"): 3}, + detail_answers=[ + ("info@yourcompany.example.com",), # company: still Odoo default + (17, None), # OdooBot user found, partner has no email + (5, "admin@acme.com"), # admin: customized, real partner_id differs from user_id + ], + ) + result = get_mail_addresses(cur) # ty: ignore[invalid-argument-type] + + assert result == [ + { + "partner_id": 1, + "label": "Company Email", + "email": "info@yourcompany.example.com", + "is_default": True, + "missing": False, + }, + { + "partner_id": 17, + "label": "System (OdooBot) Email", + "email": None, + "is_default": False, + "missing": False, + }, + {"partner_id": 5, "label": "Admin Email", "email": "admin@acme.com", "is_default": False, "missing": False}, + ] + + +def test_get_mail_addresses_empty_email(): + cur = _FakeXmlidCursor( + xmlids={("base", "main_partner"): 1, ("base", "user_root"): 17, ("base", "user_admin"): 3}, + detail_answers=[(None,), (17, None), (5, None)], + ) + result = get_mail_addresses(cur) # ty: ignore[invalid-argument-type] + + assert [r["email"] for r in result] == [None, None, None] + assert [r["is_default"] for r in result] == [False, False, False] + assert [r["missing"] for r in result] == [False, False, False] + + +def test_get_mail_addresses_is_default_is_case_insensitive(): + # Odoo never normalizes res.partner.email -- an untouched demo address + # typed back with capitals (ADMIN@Yourcompany.example.com) would + # otherwise come back is_default: False. + cur = _FakeXmlidCursor( + xmlids={("base", "main_partner"): 1, ("base", "user_root"): 17, ("base", "user_admin"): 3}, + detail_answers=[ + ("Info@YourCompany.example.com",), + (17, "OdooBot@Example.com"), + (5, "ADMIN@Yourcompany.example.com"), + ], + ) + result = get_mail_addresses(cur) # ty: ignore[invalid-argument-type] + + assert [r["is_default"] for r in result] == [True, True, True] + + +def test_get_mail_addresses_resolves_admin_by_xmlid_even_when_login_is_renamed(): + # Reproduced against a real v17 production database: res_users id 2 has + # a customer email as its login, not "admin" -- matching on + # login = 'admin' silently drops the row entirely there. Resolving + # through base.user_admin instead doesn't care what the login is. + cur = _FakeXmlidCursor( + xmlids={("base", "main_partner"): 1, ("base", "user_root"): 17, ("base", "user_admin"): 2}, + detail_answers=[("info@acme.com",), (17, "odoobot@example.com"), (9, "real.customer@acme.com")], + ) + result = get_mail_addresses(cur) # ty: ignore[invalid-argument-type] + + admin_row = result[2] + assert admin_row["label"] == "Admin Email" + assert admin_row["email"] == "real.customer@acme.com" + assert admin_row["missing"] is False + + +def test_get_mail_addresses_emits_a_missing_row_instead_of_dropping_it_when_xmlid_is_absent(): + # The admin/system row must never just vanish from the output -- "not + # listed" must not read as "no admin problem". + cur = _FakeXmlidCursor( + xmlids={("base", "main_partner"): 1}, # user_root/user_admin: absent entirely + detail_answers=[("info@acme.com",)], + ) + result = get_mail_addresses(cur) # ty: ignore[invalid-argument-type] + + assert len(result) == 3 + system_row, admin_row = result[1], result[2] + assert system_row == { + "partner_id": None, + "label": "System (OdooBot) Email", + "email": None, + "is_default": False, + "missing": True, + } + assert admin_row["missing"] is True + assert admin_row["partner_id"] is None + assert admin_row["email"] is None + + +def test_get_mail_addresses_treats_a_dangling_xmlid_as_missing_not_blank(): + # A deleted res_partner (company) or res_users (admin/system) row still + # has its ir_model_data row -- the xmlid resolves, but the detail query + # comes up empty. Must not be reported as {"email": None} indistinguishable + # from "exists, blank email". + cur = _FakeXmlidCursor( + xmlids={("base", "main_partner"): 1, ("base", "user_root"): 17, ("base", "user_admin"): 3}, + detail_answers=[None, None, None], # every detail SELECT comes up empty + ) + result = get_mail_addresses(cur) # ty: ignore[invalid-argument-type] + + for row in result: + assert row["missing"] is True + assert row["partner_id"] is None + assert row["email"] is None + + +class _FakeMailServerCursor: + """Cursor stand-in: pg_attribute probe then the ir_mail_server select.""" + + def __init__(self, has_auth_col, rows): + self._has_auth_col = has_auth_col + self._rows = rows + self._next: list = [] + + def execute(self, query, params=None): + if "pg_attribute" in query: + self._next = [(1,)] if self._has_auth_col else [] + else: + self._next = self._rows + + def fetchone(self): + return self._next[0] if self._next else None + + def fetchall(self): + return self._next + + +def test_get_mail_servers_masks_user_and_password_and_includes_15_plus_columns(): + rows = [(1, "Primary", "smtp.example.com", 587, "svc", "s3cr3t", "starttls", True, "login", "notif@example.com")] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert result == [ + { + "sequence": 1, + "name": "Primary", + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_user": "********", + "smtp_pass": "********", + "smtp_encryption": "starttls", + "smtp_authentication": "login", + "from_filter": "notif@example.com", + "active": True, + "is_test_catcher": False, + "known_production_relay": None, + "is_neutralization_stub": False, + } + ] + + +def test_get_mail_servers_flags_a_known_test_catcher_by_whole_token_match(): + rows = [ + (1, "mailhog", "smtp.example.com", 25, None, None, "none", True, "login", None), + (2, "Primary", "mailhog-acme18-staging", 2025, None, None, "none", True, "login", None), + (3, "Real Relay", "smtp.acme-internal.local", 587, None, None, "starttls", True, "login", None), + ] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert [r["is_test_catcher"] for r in result] == [True, True, False] + + +def test_get_mail_servers_test_catcher_does_not_cross_word_boundaries(): + # A plain substring check would flag maildevices.com as "maildev". + # Token matching (split on non-alphanumeric runs) fixes it. + rows = [(1, "Lookalike", "smtp.maildevices.com", 587, None, None, "starttls", True, "login", None)] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert result[0]["is_test_catcher"] is False + + +def test_get_mail_servers_test_catcher_drops_the_ambiguous_papercut_marker(): + # Papercut-SMTP is a real catcher, but "PaperCut MF/NG" is common + # print-management software -- the marker is dropped rather than guessed + # at, since a working relay reported as a dead end is worse than a + # missed catcher. + rows = [(1, "PaperCut MF print server", "print.acme.local", 25, None, None, "none", True, "login", None)] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert result[0]["is_test_catcher"] is False + + +def test_get_mail_servers_flags_mailtrap_sandbox_but_not_mailtrap_live(): + # Mailtrap runs a real sending service and a catcher sandbox on + # neighbouring hostnames -- a name/token marker can't tell them apart, + # so this one is host-based instead. + rows = [ + (1, "Mailtrap sandbox", "sandbox.smtp.mailtrap.io", 2525, None, None, "starttls", True, "login", None), + (2, "Mailtrap live", "live.smtp.mailtrap.io", 587, None, None, "starttls", True, "login", None), + ] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert [r["is_test_catcher"] for r in result] == [True, False] + + +def test_get_mail_servers_flags_google_on_any_documented_port(): + # Google documents 25, 465 and 587 for both hosts -- an earlier version + # of this table required 587/465 respectively, which missed + # smtp.gmail.com:587 with STARTTLS, the most common Odoo setup on Gmail. + rows = [ + (1, "Relay 587", "smtp-relay.gmail.com", 587, None, None, "starttls", True, "login", None), + (2, "Relay 25", "smtp-relay.gmail.com", 25, None, None, "none", True, "login", None), + (3, "Direct 587", "smtp.gmail.com", 587, None, None, "starttls", True, "login", None), + (4, "Wrong port", "smtp-relay.gmail.com", 2525, None, None, "starttls", True, "login", None), + ] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert [r["known_production_relay"] for r in result] == [ + "Google Workspace SMTP relay", + "Google Workspace SMTP relay", + "Gmail SMTP", + None, + ] + + +def test_get_mail_servers_flags_a_known_production_relay_by_suffix_and_rejects_lookalikes(): + rows = [ + # M365: host is a per-tenant subdomain, so this must be a suffix match + (1, "M365", "acme-com.mail.protection.outlook.com", 25, None, None, "none", True, "login", None), + # a bare endswith() (no leading dot) would match both of these + (2, "Lookalike Mailjet", "notmailjet.com", 587, None, None, "starttls", True, "login", None), + (3, "Lookalike M365", "evilmail.protection.outlook.com", 25, None, None, "none", True, "login", None), + (4, "Unrelated", "smtp.acme-internal.local", 587, None, None, "starttls", True, "login", None), + ] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert [r["known_production_relay"] for r in result] == ["Microsoft 365", None, None, None] + + +def test_get_mail_servers_flags_microsoft_365_smtp_auth_client_submission(): + # The usual Odoo-on-M365 setup: SMTP AUTH client submission needs no + # Exchange-side connector, just a mailbox user/password -- different + # hosts from the per-tenant relay-connector suffix above, and + # deliberately port-agnostic (a separate table entry, not a widened + # one, so the relay connector's port-25 constraint stays intact). + rows = [ + (1, "M365 direct", "smtp.office365.com", 587, None, None, "starttls", True, "login", None), + (2, "M365 outlook", "smtp-mail.outlook.com", 587, None, None, "starttls", True, "login", None), + ] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert [r["known_production_relay"] for r in result] == ["Microsoft 365", "Microsoft 365"] + + +def test_get_mail_servers_flags_port_agnostic_known_relays_by_host_alone(): + """These hosts are dedicated per-provider hostnames -- distinctive + enough on their own that a port requirement would just be extra + fragility, not extra precision. Unlike Google/Gmail (see + test_get_mail_servers_flags_google_on_any_documented_port), none of + these carry a port constraint at all.""" + rows = [ + (1, "Brevo old host", "smtp-relay.sendinblue.com", 587, None, None, "starttls", True, "login", None), + (2, "Brevo new host", "smtp-relay.brevo.com", 587, None, None, "starttls", True, "login", None), + (3, "Mandrill", "smtp.mandrillapp.com", 587, None, None, "starttls", True, "login", None), + (4, "OVH", "ssl0.ovh.net", 465, None, None, "ssl", True, "login", None), + (5, "Mailjet EU", "in-v3.mailjet.com", 587, None, None, "starttls", True, "login", None), + (6, "SendGrid", "smtp.sendgrid.net", 587, None, None, "starttls", True, "login", None), + (7, "Mailgun", "smtp.mailgun.org", 587, None, None, "starttls", True, "login", None), + (8, "Postmark", "smtp.postmarkapp.com", 587, None, None, "starttls", True, "login", None), + ] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert [r["known_production_relay"] for r in result] == [ + "Brevo (ex-Sendinblue)", + "Brevo (ex-Sendinblue)", + "Mandrill", + "OVH", + "Mailjet", + "SendGrid", + "Mailgun", + "Postmark", + ] + + +def test_get_mail_servers_flags_amazon_ses_by_region_pattern_and_rejects_unrelated_amazonaws_hosts(): + rows = [ + (1, "SES us-east-1", "email-smtp.us-east-1.amazonaws.com", 587, None, None, "starttls", True, "login", None), + (2, "SES eu-west-1", "email-smtp.eu-west-1.amazonaws.com", 587, None, None, "starttls", True, "login", None), + # an unrelated amazonaws.com host (e.g. an EC2 instance's own + # hostname) must not be reported as SES + (3, "Unrelated EC2", "ec2-1-2-3-4.compute-1.amazonaws.com", 25, None, None, "none", True, "login", None), + ] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert [r["known_production_relay"] for r in result] == ["Amazon SES", "Amazon SES", None] + + +def test_get_mail_servers_reveals_user_and_password_when_asked(): + rows = [(1, "Primary", "smtp.example.com", 587, "svc", "s3cr3t", "starttls", True, "login", "notif@example.com")] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur, reveal=True) # ty: ignore[invalid-argument-type] + + assert result[0]["smtp_user"] == "svc" + assert result[0]["smtp_pass"] == "s3cr3t" # noqa: S105 — test fixture value, not a real secret + + +def test_get_mail_servers_pre_15_has_no_auth_columns(): + rows = [(1, "Primary", "smtp.example.com", 25, None, None, "none", True, None, None)] + cur = _FakeMailServerCursor(has_auth_col=False, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert result[0]["smtp_authentication"] is None + assert result[0]["from_filter"] is None + assert result[0]["smtp_user"] is None # no username set -> nothing to mask + assert result[0]["smtp_pass"] is None # no password set -> nothing to mask + + +def test_get_mail_servers_flags_the_neutralization_stub_odoo_core_inserts(): + # base/data/neutralize.sql inserts exactly this (name, host) pair after + # disabling every pre-existing relay -- the real relay below it is the + # one that's now inactive, not broken. + rows = [ + (None, "neutralization - disable emails", "invalid", 1025, None, None, "none", True, "login", None), + (1, "Real Relay", "smtp.sendgrid.net", 587, None, None, "starttls", False, "login", None), + ] + cur = _FakeMailServerCursor(has_auth_col=True, rows=rows) + result = get_mail_servers(cur) # ty: ignore[invalid-argument-type] + + assert [r["is_neutralization_stub"] for r in result] == [True, False] + + +def test_is_neutralization_stub_mail_server_requires_both_name_and_host(): + assert _is_neutralization_stub_mail_server("neutralization - disable emails", "invalid") is True + # Case-insensitive: Odoo core's own string, but nothing guarantees casing. + assert _is_neutralization_stub_mail_server("Neutralization - Disable Emails", "Invalid") is True + assert _is_neutralization_stub_mail_server("neutralization - disable emails", "smtp.example.com") is False + assert _is_neutralization_stub_mail_server("Primary", "invalid") is False + assert _is_neutralization_stub_mail_server(None, None) is False + + +class _FakeConfigParamCursor: + """Cursor stand-in: one queued fetchone() result for a single-key select.""" + + def __init__(self, row): + self._row = row + + def execute(self, query, params=None): + pass + + def fetchone(self): + return self._row + + +def test_get_is_neutralized_case_folds_postgres_lowercase_true(): + # base/data/neutralize.sql writes the value via a plain SQL boolean + # literal (`VALUES (..., true)`), which Postgres stores as lowercase + # text 'true' -- a `== "True"` comparison here would silently report + # every neutralized database as not neutralized. + cur = _FakeConfigParamCursor(("true",)) + assert get_is_neutralized(cur) is True # ty: ignore[invalid-argument-type] + + +def test_get_is_neutralized_false_when_key_absent(): + cur = _FakeConfigParamCursor(None) + assert get_is_neutralized(cur) is False # ty: ignore[invalid-argument-type] + + +def test_get_is_neutralized_false_when_explicitly_false(): + cur = _FakeConfigParamCursor(("false",)) + assert get_is_neutralized(cur) is False # ty: ignore[invalid-argument-type] + + +class _FakeAliasDomainCursor: + """Cursor stand-in: to_regclass probe then the res_company/mail_alias_domain join.""" + + def __init__(self, table_exists, rows=()): + self._table_exists = table_exists + self._rows = list(rows) + self._next: list = [] + self.main_query: str | None = None + + def execute(self, query, params=None): + if "to_regclass" in query: + self._next = [(1 if self._table_exists else None,)] + return + self.main_query = query + self._next = self._rows + + def fetchone(self): + return self._next[0] if self._next else None + + def fetchall(self): + return self._next + + +def test_get_mail_alias_domains_returns_none_when_table_absent(): + cur = _FakeAliasDomainCursor(table_exists=False) + assert get_mail_alias_domains(cur) is None # ty: ignore[invalid-argument-type] + + +def test_get_mail_alias_domains_computes_emails_and_flags_missing(): + rows = [ + # company with an alias domain assigned + (1, "My Company", 3, "mycompany.example", "bounce", "catchall", "notifications"), + # company with no alias domain at all + (2, "Other Company", None, None, None, None, None), + # default_from already a full address -> used as-is, not "@"-appended again + (4, "Third Company", 5, "third.example.com", "bounce", "catchall", "no-reply@third.example.com"), + ] + cur = _FakeAliasDomainCursor(table_exists=True, rows=rows) + result = get_mail_alias_domains(cur) # ty: ignore[invalid-argument-type] + + # Archived companies shouldn't feed the "missing alias domain" gauge with + # false positives — regression check that the filter is actually in the query. + assert cur.main_query is not None + assert "c.active = true" in cur.main_query + + assert result == [ + { + "company_id": 1, + "company_name": "My Company", + "alias_domain_id": 3, + "alias_domain": "mycompany.example", + "bounce_email": "bounce@mycompany.example", + "catchall_email": "catchall@mycompany.example", + "default_from_email": "notifications@mycompany.example", + }, + { + "company_id": 2, + "company_name": "Other Company", + "alias_domain_id": None, + "alias_domain": None, + "bounce_email": None, + "catchall_email": None, + "default_from_email": None, + }, + { + "company_id": 4, + "company_name": "Third Company", + "alias_domain_id": 5, + "alias_domain": "third.example.com", + "bounce_email": "bounce@third.example.com", + "catchall_email": "catchall@third.example.com", + "default_from_email": "no-reply@third.example.com", + }, + ] + + class _FakeSeqCursor: """Cursor stand-in returning one queued result set per execute()."""