From a0a003ec83f7d2f9ade11c458b884f200b3badd0 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 01:20:21 +0000 Subject: [PATCH 1/5] Add message groups and durable action previews to SDK and CLI --- doc/cli/email-security.md | 30 ++++ limacharlie/commands/mailsec.py | 128 ++++++++++++++++ limacharlie/discovery.py | 5 + limacharlie/sdk/mailsec.py | 206 +++++++++++++++++++++++++- tests/unit/test_cli_mailsec_groups.py | 65 ++++++++ tests/unit/test_sdk_mailsec_groups.py | 104 +++++++++++++ 6 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_cli_mailsec_groups.py create mode 100644 tests/unit/test_sdk_mailsec_groups.py diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index f32eb4eb..fd3f4e67 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -346,3 +346,33 @@ seconds) to poll for a ready preview or a resolved report. Timeout exits with code 2, retaining the job; repeat with the same attempt and confirmation to resume. A failed or withheld job needs a new attempt and fresh confirmation. Only the reported original is classified; every selected copy is remediated. + +## Message groups + +A group is one hardened message delivered to many recipients. A campaign contains +similar messages. Groups carry separate engine verdict/severity and analyst +disposition summaries. The default group list is the flagged triage queue; use +`--all` to include other groups. Aggregates expose an `as_of` timestamp. + +```bash +limacharlie mailsec group list --severity high --severity critical --disposition none +limacharlie mailsec group get +limacharlie mailsec message list --group-id --severity high +limacharlie mailsec group preview --action quarantine_message --reason "Incident review" +limacharlie mailsec group status +limacharlie mailsec group confirm --confirmation +``` + +Preview requires `mailsec.act` and prepares **every** recipient copy in a durable +job. There is no 500-copy truncation. It never executes automatically and has no +confirmation token until paging is complete. `--preview-id` is printed before +the request; reuse it with identical parameters on retry. `--force`, `--reason` +and optional banner `--text` are frozen in the preview. Confirmation must use +the original authenticated actor and cannot change those parameters. Copies +delivered after the snapshot need a new preview. + +Both preview and confirmation wait by default, with a bounded `--timeout` and +`--poll-interval`; `--no-wait` returns the job immediately. A polling timeout or +error leaves the job intact. Inspect it with `group status`; execution resumes +across worker handover. Failed or withheld recipient outcomes return a nonzero +CLI exit code. Repeated confirmation adopts the same execution. diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index cfa26fff..e89cb7ae 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -37,6 +37,7 @@ import json import re +import uuid from typing import Any import click @@ -904,6 +905,7 @@ def group() -> None: coverage Mailbox coverage and analysed volume message ... The triage queue, drawer, raw EML, similar, actions, revise, and bulk remediation of a selection + group ... Flagged groups, aggregate details and durable all-recipient actions campaign ... Campaigns and campaign-wide sweeps sender get A sender's history with this org action get One record from the action audit trail @@ -918,6 +920,119 @@ def group() -> None: """ +@group.group("group") +def message_groups() -> None: + """Flagged message groups and resumable actions on every recipient copy.""" + + +@message_groups.command("list") +@click.option("--verdict", multiple=True, type=click.Choice(["malicious", "suspicious", "graymail", "benign", "unknown", "error"])) +@click.option("--severity", multiple=True, type=click.Choice(["informational", "low", "medium", "high", "critical"])) +@click.option("--disposition", multiple=True, type=click.Choice(["malicious", "spam", "graymail", "benign", "simulation", "none"])) +@click.option("--user-reported", default=None, type=click.Choice(["true", "false"])) +@click.option("--all", "all_groups", is_flag=True, help="Include groups outside the flagged triage queue.") +@click.option("--since", default=None, help="Earliest last-seen time (RFC3339 or unix seconds).") +@click.option("--until", default=None, help="Exclusive latest last-seen time.") +@click.option("--cursor", default=None, help="Opaque cursor; keep all filters unchanged.") +@click.option("--limit", default=None, type=click.IntRange(1, 500), help="Page size.") +@pass_context +def groups_list(ctx, verdict, severity, disposition, user_reported, all_groups, since, until, cursor, limit) -> None: + """List the flagged-group triage queue, newest last-seen first.""" + _output(ctx, _get_mailsec(ctx).list_groups( + verdict=list(verdict) or None, severity=list(severity) or None, + disposition=list(disposition) or None, + user_reported=None if user_reported is None else user_reported == "true", + all_groups=all_groups, since=since, until=until, cursor=cursor, limit=limit, + )) + + +@message_groups.command("get") +@click.argument("group_id") +@pass_context +def groups_get(ctx, group_id) -> None: + """Read exact message/recipient counts, placement and disposition summaries.""" + _output(ctx, _get_mailsec(ctx).get_group(group_id)) + + +def _group_wait_output(ctx, ms, job_id, preparing, timeout, poll_interval) -> None: + try: + status = ms.wait_for_group_action(job_id, preparing=preparing, timeout=timeout, poll_interval=poll_interval) + except Exception: + note(ctx, f"Job {job_id} is unaffected; inspect it with: limacharlie mailsec group status {job_id}") + raise + _output(ctx, status) + job = status.get("job", {}) + phase = job.get("phase") + if phase == "failed" or (preparing and phase not in {"ready", "running", "done"}) or (not preparing and phase != "done"): + note(ctx, f"Job {job_id} has not completed successfully; inspect its status.") + ctx.exit(1) + if not preparing and (job.get("failed", 0) or job.get("withheld", 0)): + note(ctx, "Some recipient copies failed or were withheld by alert-only mode. Check job totals before continuing.") + ctx.exit(1) + + +@message_groups.command("preview") +@click.argument("group_id") +@click.option("--action", "action_name", required=True, type=click.Choice(BULK_ACTIONS)) +@click.option("--preview-id", default=None, help="UUID for retrying the identical frozen request; printed before preparation.") +@click.option("--reason", default=None, help="Audited justification, frozen before confirmation.") +@click.option("--text", default=None, help="Plain-text banner override, at most 512 characters, frozen before confirmation.") +@click.option("--force", is_flag=True, help="Explicitly override alert-only mode; frozen in this preview.") +@click.option("--wait/--no-wait", default=True, help="Wait for a complete preview (default). Never executes it.") +@click.option("--timeout", default=300, type=click.IntRange(1, 3600)) +@click.option("--poll-interval", default=3, type=click.IntRange(1, 60)) +@pass_context +def groups_preview(ctx, group_id, action_name, preview_id, reason, text, force, wait, timeout, poll_interval) -> None: + """Prepare every recipient copy; confirmation is issued only after complete paging. + + Reuse --preview-id when retrying the same action/force/reason/text. Copies delivered + after the frozen snapshot are excluded. No mailbox action occurs before confirmation. + """ + preview_id = preview_id or str(uuid.uuid4()) + note(ctx, f"Preview identity: {preview_id}. Reuse it with identical parameters on retry.") + ms = _get_mailsec(ctx) + status = ms.prepare_group_action(group_id, action_name, preview_id, force=force, reason=reason, text=text) + job_id = status.get("job", {}).get("job_id") + if not job_id: + _output(ctx, status) + raise click.ClickException("Preview returned no durable job ID; no execution was confirmed.") + note(ctx, f"Group action job: {job_id}") + if wait: + _group_wait_output(ctx, ms, job_id, True, timeout, poll_interval) + else: + _output(ctx, status) + + +@message_groups.command("status") +@click.argument("job_id") +@pass_context +def groups_status(ctx, job_id) -> None: + """Read durable preview/execution progress and outcome counts.""" + _output(ctx, _get_mailsec(ctx).get_group_action(job_id)) + + +@message_groups.command("confirm") +@click.argument("job_id") +@click.option("--confirmation", required=True, help="Token from the complete preview; must use the actor that prepared it.") +@click.option("--wait/--no-wait", default=True, help="Wait for recipient outcomes (default), or return the accepted job.") +@click.option("--timeout", default=300, type=click.IntRange(1, 3600)) +@click.option("--poll-interval", default=3, type=click.IntRange(1, 60)) +@pass_context +def groups_confirm(ctx, job_id, confirmation, wait, timeout, poll_interval) -> None: + """Execute the frozen preview; repeat confirmation safely adopts the same job. + + The action, force, reason, text and recipient snapshot cannot change here. Polls + are bounded; a timeout leaves the resumable job running and prints its handle. + """ + ms = _get_mailsec(ctx) + status = ms.confirm_group_action(job_id, confirmation) + note(ctx, f"Group action job: {job_id}") + if wait: + _group_wait_output(ctx, ms, job_id, False, timeout, poll_interval) + else: + _output(ctx, status) + + @group.group("message") def message_group() -> None: """The message index, drawer, raw EML, similar mail, actions, verdict revision, @@ -1051,6 +1166,8 @@ def onboarding(ctx, provider, project_id, sa_email, topic, subscription) -> None @click.option("--sender-email", default=None, help="Sender address (exact).") @click.option("--sender-domain", default=None, help="Sender registrable root domain.") @click.option("--campaign-id", default=None, help="Only members of this campaign.") +@click.option("--group-id", default=None, help="Restrict to recipient copies of one message group.") +@click.option("--severity", multiple=True, type=click.Choice(["informational","low","medium","high","critical"]), help="Rule impact (repeatable).") @click.option("--state", multiple=True, help="Message state (repeatable).") @click.option("--direction", multiple=True, help="inbound|outbound|internal (repeatable).") @click.option("--disposition", default=None, type=click.Choice([*DISPOSITIONS, "none"]), help="Independent analyst/SOAR disposition; none selects untriaged.") @@ -1070,6 +1187,9 @@ def onboarding(ctx, provider, project_id, sa_email, topic, subscription) -> None @pass_context def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id, state, direction, lane, disposition, user_reported, no_user_reported, min_score, link_domain, + +def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id, group_id, severity, state, + direction, lane, user_reported, no_user_reported, min_score, link_domain, attachment_sha256, q, since, until, cursor, limit) -> None: """The message index — the triage queue. @@ -1094,6 +1214,8 @@ def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id state=list(state) or None, direction=list(direction) or None, **lane_params, + **({"group_id":group_id} if group_id else {}), + **({"severity":list(severity)} if severity else {}), user_reported=_tri_state(user_reported, no_user_reported, "user-reported"), min_score=min_score, link_domain=link_domain, @@ -1901,3 +2023,9 @@ def message_release(ctx, msg_uuid, reason, mode, force) -> None: raise click.UsageError(str(exc)) from exc _output(ctx, result) _note_force_required(ctx, result, "Re-run with --force to release it.", force) + +register_explain("mailsec.group.list", "List the flagged message-group queue with severity, verdict, disposition and report filters. Counts carry as_of timestamps; keep filters unchanged while paging.") +register_explain("mailsec.group.get", "Read exact counts and summaries for every recipient copy, including a representative message and campaign. Use message list --group-id to page instances.") +register_explain("mailsec.group.preview", "Freeze all copies and action parameters in a resumable job. Reuse --preview-id for retries. Preparation never executes; wait for ready and inspect its counts/token before confirming.") +register_explain("mailsec.group.status", "Read preparing|ready|running|done|failed, snapshot membership and success/skipped/withheld/failed counts. Preparing jobs have no confirmation token.") +register_explain("mailsec.group.confirm", "Execute the completely prepared snapshot using its token and original actor. Frozen parameters cannot change. Repeated confirmation adopts the same job; timeouts leave it running.") diff --git a/limacharlie/discovery.py b/limacharlie/discovery.py index fb1f75c5..6862a34b 100644 --- a/limacharlie/discovery.py +++ b/limacharlie/discovery.py @@ -270,6 +270,11 @@ "mailsec message revise", "mailsec message revisions", "mailsec message disposition", "mailsec message bulk-disposition", "mailsec message release", "mailsec message bulk-action", "mailsec message bulk-status", + "mailsec group list", + "mailsec group get", + "mailsec group preview", + "mailsec group status", + "mailsec group confirm", "mailsec campaign list", "mailsec campaign get", "mailsec campaign action", "mailsec sender get", "mailsec action get", diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 86bdf912..b58db219 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -50,6 +50,7 @@ import base64 import binascii import json +import re import time import warnings import uuid @@ -207,6 +208,47 @@ def _add_scalar(pairs: list[tuple[str, str]], key: str, value: Any) -> None: pairs.append((key, str(value))) + + +def _warn_banner_is_ignored(banner: str | None) -> None: + """Warn once per call that a caller-supplied banner goes nowhere. + + The banner used to travel on the request and was spliced into the + recipient's mailbox verbatim, so any caller holding ``mailsec.act`` chose + HTML that ran in someone else's mail client. It is now rendered by the + server from the organization's ``mailsec_policy`` record of type + ``banners``, escaped into a fixed template. + + The argument is kept and IGNORED rather than rejected, for one release: an + existing script keeps working and simply gets the organization's configured + banner, which is what it wanted. The warning is what stops that from being a + silent change — a field that quietly stops meaning anything is worse than + one that says so. + """ + if banner is None: + return + warnings.warn( + "mailsec: the `banner` argument is deprecated and ignored. The warning banner is " + "rendered by the server from the organization's mailsec_policy record of type " + "'banners' (its `text`), so that no caller can inject markup into a user's mailbox. " + "Set the wording there instead; this argument will be removed.", + DeprecationWarning, + stacklevel=3, + ) + + +def _group_identity(value: str) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value): + raise ValueError("group_id must be a lowercase SHA-256 identity") + return value + + +def _group_job_identity(value: str) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", value): + raise ValueError("job/preview identity must be a lowercase UUID") + return value + + class Mailsec: """Email Security client for LimaCharlie.""" @@ -308,6 +350,8 @@ def list_messages( self, *, verdict: list[str] | None = None, + severity: list[str] | None = None, + group_id: str | None = None, mailbox: str | None = None, sender_email: str | None = None, sender_domain: str | None = None, @@ -339,6 +383,8 @@ def list_messages( ``sender_root_domain``; the Python name is retained for compatibility with existing callers. campaign_id: Only members of one campaign. + group_id: Only recipient copies in one message group. + severity: Rule impact (informational, low, medium, high, critical), repeatable. state: Message lifecycle state (repeatable). direction: ``inbound``, ``outbound``, ``internal`` (repeatable). disposition: Analyst/SOAR label or ``none`` for untriaged. @@ -376,15 +422,17 @@ def list_messages( if q: if len(q) > 512: raise ValueError("q must be at most 512 code points") - bounded = any((since, mailbox, sender_email, campaign_id, link_domain, attachment_sha256)) + bounded = any((since, mailbox, sender_email, campaign_id, group_id, link_domain, attachment_sha256)) single_verdict = verdict is not None and len(verdict) == 1 and bool(verdict[0].strip()) - if not bounded and not single_verdict: + single_severity = severity is not None and len(severity) == 1 and bool(severity[0].strip()) + if not bounded and not single_verdict and not single_severity: raise ValueError( "q requires since, mailbox, sender_email, campaign_id, " - "link_domain, attachment_sha256, or exactly one verdict" + "group_id, link_domain, attachment_sha256, or exactly one verdict/severity" ) pairs: list[tuple[str, str]] = [] _add_pairs(pairs, "verdict", verdict) + _add_pairs(pairs, "severity", severity) _add_pairs(pairs, "state", state) _add_pairs(pairs, "direction", direction) for key, val in ( @@ -392,6 +440,7 @@ def list_messages( ("sender_email", sender_email), ("sender_root_domain", sender_domain), ("campaign_id", campaign_id), + ("group_id", group_id), ("min_score", min_score), ("link_domain", link_domain), ("attachment_sha256", attachment_sha256), @@ -939,6 +988,157 @@ def wait_for_bulk( return status time.sleep(min(poll_interval, remaining)) + # ------------------------------------------------------------------ + # Message groups and durable all-recipient actions + # ------------------------------------------------------------------ + + def list_groups( + self, *, verdict: list[str] | None = None, severity: list[str] | None = None, + disposition: list[str] | None = None, user_reported: bool | None = None, + all_groups: bool = False, since: str | None = None, until: str | None = None, + cursor: str | None = None, limit: int | None = None, + ) -> dict[str, Any]: + """List the flagged message-group queue, ordered by last seen. + + Args: + verdict: Repeatable engine verdict filter. + severity: Repeatable severity filter. + disposition: Repeatable analyst disposition filter, including none. + user_reported: True or false to constrain reports; None is unconstrained. + all_groups: Include groups outside the flagged queue. + since: Earliest last-seen time, RFC3339 or unix seconds. + until: Exclusive latest last-seen time. + cursor: Opaque cursor bound to the complete filter set. + limit: Page size. + + Returns: + dict: Groups, next_cursor and materialized as_of timestamps. + """ + pairs: list[tuple[str, str]] = [] + for key, values in (("verdict", verdict), ("severity", severity), ("disposition", disposition)): + _add_pairs(pairs, key, values) + _add_scalar(pairs, "user_reported", user_reported) + if not isinstance(all_groups, bool): + raise TypeError("all_groups must be a boolean") + if all_groups: + _add_scalar(pairs, "all", True) + for key, value in (("since", since), ("until", until), ("cursor", cursor), ("limit", limit)): + _add_scalar(pairs, key, value) + return self._get("groups", pairs) + + def get_group(self, group_id: str) -> dict[str, Any]: + """Read one consistent aggregate of every recipient copy. + + Args: + group_id: The deterministic message-group identity. + + Returns: + dict: Group with exact recipient/message counts and an as_of timestamp. + + Raises: + ValueError: If group_id is not a lowercase SHA-256 identity. + """ + return self._get(f"groups/{_group_identity(group_id)}") + + def prepare_group_action( + self, group_id: str, action: str, preview_id: str, *, + force: bool = False, reason: str | None = None, text: str | None = None, + ) -> dict[str, Any]: + """Freeze all recipient copies and parameters in a durable preview job. + + Args: + group_id: Message-group identity. + action: Remediation action name. + preview_id: Caller-minted UUID; reuse it when retrying identical parameters. + force: Explicit override of organization alert-only mode. + reason: Audited reason, frozen before confirmation. + text: Optional plain-text banner override, frozen before confirmation. + + Returns: + dict: Job initially preparing; no token until every member is snapshotted. + + Raises: + ValueError: If either identity is malformed. + TypeError: If force is not a boolean. + """ + body: dict[str, Any] = {"action": action, "preview_id": _group_job_identity(preview_id)} + if _check_force(force): + body["force"] = True + for key, value in (("reason", reason), ("text", text)): + if value is not None: + body[key] = value + return self._post(f"groups/{_group_identity(group_id)}/actions/preview", body) + + def get_group_action(self, job_id: str) -> dict[str, Any]: + """Read preparation/execution progress for a durable group action. + + Args: + job_id: Durable job UUID. + + Returns: + dict: Job phase, frozen membership/outcome counts and token when ready. + + Raises: + ValueError: If job_id is malformed. + """ + return self._get(f"group-actions/{_group_job_identity(job_id)}") + + def confirm_group_action(self, job_id: str, confirmation: str) -> dict[str, Any]: + """Execute a completed preview as its original authenticated actor. + + Args: + job_id: Durable job UUID. + confirmation: Token from the completely prepared preview. + + Returns: + dict: Running job; repeated confirmation adopts the same execution. + + Raises: + ValueError: If job_id or the token is malformed. + """ + if not isinstance(confirmation, str) or not re.fullmatch(r"[0-9a-f]{64}", confirmation): + raise ValueError("confirmation must be the complete preview's token") + return self._post(f"group-actions/{_group_job_identity(job_id)}/confirm", {"confirmation": confirmation}) + + def wait_for_group_action( + self, job_id: str, *, preparing: bool = False, timeout: int = 300, + poll_interval: int = 3, + ) -> dict[str, Any]: + """Poll boundedly for a ready preview or completed execution. + + Args: + job_id: Durable job UUID. + preparing: Stop at ready when True; otherwise wait for done or failed. + timeout: Maximum seconds, from 1 to 3600. + poll_interval: Seconds between polls, from 1 to 60. + + Returns: + dict: Last status; a nonterminal phase means the deadline expired. + + Raises: + ValueError: If polling bounds or job identity are invalid. + RuntimeError: If the server returns an unknown or missing phase. + """ + _group_job_identity(job_id) + if type(timeout) is not int or not 1 <= timeout <= 3600: + raise ValueError("timeout must be an integer from 1 to 3600 seconds") + if type(poll_interval) is not int or not 1 <= poll_interval <= 60: + raise ValueError("poll_interval must be an integer from 1 to 60 seconds") + deadline = time.monotonic() + timeout + terminal = {"done", "failed"} | ({"ready", "running"} if preparing else set()) + for _ in range(timeout // poll_interval + 2): + status = self.get_group_action(job_id) + phase = status.get("job", {}).get("phase") + if phase not in {"preparing", "ready", "running", "done", "failed"}: + raise RuntimeError("group action returned an unknown or missing phase") + if phase in terminal: + return status + remaining = deadline - time.monotonic() + if remaining <= 0: + return status + time.sleep(min(poll_interval, remaining)) + return status + # ------------------------------------------------------------------ # Campaigns # ------------------------------------------------------------------ diff --git a/tests/unit/test_cli_mailsec_groups.py b/tests/unit/test_cli_mailsec_groups.py new file mode 100644 index 00000000..3f8d9bd7 --- /dev/null +++ b/tests/unit/test_cli_mailsec_groups.py @@ -0,0 +1,65 @@ +import json +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from limacharlie.cli import cli + +OID = "11111111-1111-4111-8111-111111111111" +GID = "a" * 64 +JOB = "22222222-2222-4222-8222-222222222222" + + +def invoke(*args, phase="ready", withheld=0): + with (patch("limacharlie.commands.mailsec.Client"), patch("limacharlie.commands.mailsec.Organization"), patch("limacharlie.commands.mailsec.Mailsec") as factory): + ms = MagicMock() + factory.return_value = ms + status = {"job": {"job_id": JOB, "phase": phase, "withheld": withheld, "failed": 0}, "confirmation": "b" * 64} + ms.prepare_group_action.return_value = status + ms.confirm_group_action.return_value = status + ms.wait_for_group_action.return_value = status + ms.list_groups.return_value = {"groups": [], "next_cursor": "opaque"} + result = CliRunner(mix_stderr=False).invoke(cli, ["--oid", OID, "--output", "json", "mailsec", "group", *args]) + return result, ms + + +def test_preview_never_executes_and_freezes_fields(): + result, ms = invoke("preview", GID, "--action", "banner_message", "--preview-id", JOB, "--text", "Review", "--reason", "Fixture", "--force") + assert result.exit_code == 0, result.output + ms.prepare_group_action.assert_called_once_with(GID, "banner_message", JOB, force=True, reason="Fixture", text="Review") + ms.confirm_group_action.assert_not_called() + assert json.loads(result.stdout)["job"]["phase"] == "ready" + assert JOB in result.stderr + + +def test_confirmation_has_no_action_force_or_text_surface(): + result, ms = invoke("confirm", JOB, "--confirmation", "b" * 64, "--no-wait", phase="running") + assert result.exit_code == 0, result.output + ms.confirm_group_action.assert_called_once_with(JOB, "b" * 64) + ms.prepare_group_action.assert_not_called() + ms.wait_for_group_action.assert_not_called() + + +def test_done_with_withheld_copies_exits_nonzero_but_preserves_job(): + result, ms = invoke("confirm", JOB, "--confirmation", "b" * 64, phase="done", withheld=7) + assert result.exit_code == 1 + assert json.loads(result.stdout)["job"]["withheld"] == 7 + assert "withheld" in result.stderr + + +def test_preview_timeout_returns_handle_without_confirmation(): + result, ms = invoke("preview", GID, "--action", "trash_message", "--preview-id", JOB, phase="preparing") + assert result.exit_code == 1 + assert json.loads(result.stdout)["job"]["job_id"] == JOB + ms.confirm_group_action.assert_not_called() + + +def test_group_filters_and_severity_choices(): + result, ms = invoke("list", "--severity", "critical", "--disposition", "none", "--user-reported", "false", "--all") + assert result.exit_code == 0, result.output + assert ms.list_groups.call_args.kwargs["severity"] == ["critical"] + assert ms.list_groups.call_args.kwargs["user_reported"] is False + assert ms.list_groups.call_args.kwargs["all_groups"] is True + result, ms = invoke("list", "--severity", "urgent") + assert result.exit_code != 0 + ms.list_groups.assert_not_called() diff --git a/tests/unit/test_sdk_mailsec_groups.py b/tests/unit/test_sdk_mailsec_groups.py new file mode 100644 index 00000000..6fda27a5 --- /dev/null +++ b/tests/unit/test_sdk_mailsec_groups.py @@ -0,0 +1,104 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from limacharlie.sdk.mailsec import Mailsec + +OID = "11111111-1111-4111-8111-111111111111" +GID = "a" * 64 +JOB = "22222222-2222-4222-8222-222222222222" + +@pytest.fixture +def client(): + org = MagicMock() + org.oid = OID + return Mailsec(org), org.client + + +def test_groups_preserve_repeated_filters_and_false_report_constraint(client): + ms, transport = client + ms.list_groups(severity=["medium", "critical"], disposition=["malicious", "none"], user_reported=False, all_groups=True, cursor="opaque", limit=25) + args, kwargs = transport.request.call_args + assert args == ("GET", f"mailsec/{OID}/groups") + pairs = kwargs["query_params"] + assert [(k, v) for k, v in pairs if k == "severity"] == [("severity", "medium"), ("severity", "critical")] + assert ("user_reported", "false") in pairs + assert ("all", "true") in pairs + assert ("cursor", "opaque") in pairs + assert ("disposition", "none") in pairs + assert ("limit", "25") in pairs + + +def test_group_preview_freezes_force_reason_text_and_client_identity(client): + ms, transport = client + ms.prepare_group_action(GID, "banner_message", JOB, force=True, reason="Fixture review", text="Review this message") + args, kwargs = transport.request.call_args + assert args == ("POST", f"mailsec/{OID}/groups/{GID}/actions/preview") + body = json.loads(kwargs["raw_body"]) + assert body == {"action": "banner_message", "preview_id": JOB, "force": True, "reason": "Fixture review", "text": "Review this message"} + ms.confirm_group_action(JOB, "b" * 64) + args, kwargs = transport.request.call_args + assert args == ("POST", f"mailsec/{OID}/group-actions/{JOB}/confirm") + assert json.loads(kwargs["raw_body"]) == {"confirmation": "b" * 64} + + +@pytest.mark.parametrize("value", ["", "../other", "a" * 63, "A" * 64]) +def test_group_identity_refused_before_transport(client, value): + ms, transport = client + with pytest.raises(ValueError): + ms.get_group(value) + transport.request.assert_not_called() + + +def test_preview_force_must_be_boolean(client): + ms, transport = client + with pytest.raises(TypeError): + ms.prepare_group_action(GID, "trash_message", JOB, force="true") + transport.request.assert_not_called() + + +def test_polling_stops_at_ready_without_executing(client): + ms, transport = client + transport.request.side_effect = [{"job": {"phase": "preparing"}}, {"job": {"phase": "ready"}, "confirmation": "b" * 64}] + with patch("limacharlie.sdk.mailsec.time.sleep"): + status = ms.wait_for_group_action(JOB, preparing=True, timeout=5, poll_interval=1) + assert status["job"]["phase"] == "ready" + assert len(transport.request.call_args_list) == 2 + assert all(call.args[0] == "GET" for call in transport.request.call_args_list) + + +def test_polling_unknown_phase_is_an_error(client): + ms, transport = client + transport.request.return_value = {"job": {"phase": "unknown"}} + with pytest.raises(RuntimeError): + ms.wait_for_group_action(JOB) + + +@pytest.mark.parametrize("timeout, interval", [(float("inf"), 3), (True, 3), (3601, 3), (5, 0), (5, 61)]) +def test_polling_bounds_refused_before_transport(client, timeout, interval): + ms, transport = client + with pytest.raises(ValueError): + ms.wait_for_group_action(JOB, timeout=timeout, poll_interval=interval) + transport.request.assert_not_called() + + +def test_deadline_returns_last_running_job_and_never_reconfirms(client): + ms, transport = client + transport.request.return_value = {"job": {"phase": "running", "job_id": JOB}} + with patch("limacharlie.sdk.mailsec.time.monotonic", side_effect=[0, 10]): + result = ms.wait_for_group_action(JOB, timeout=1) + assert result["job"]["phase"] == "running" + assert transport.request.call_count == 1 + assert transport.request.call_args.args[0] == "GET" + + +def test_message_group_and_severity_filters_forward_and_narrow_search(client): + ms, transport = client + ms.list_messages(group_id=GID, severity=["high", "critical"], q="fixture") + pairs = transport.request.call_args.kwargs["query_params"] + assert ("group_id", GID) in pairs + assert [(key, value) for key, value in pairs if key == "severity"] == [("severity", "high"), ("severity", "critical")] + ms.list_messages(severity=["critical"], q="fixture") + with pytest.raises(ValueError): + ms.list_messages(severity=["high", "critical"], q="fixture") From d5e1b57e0f3cb88fbf3e534ca01898508603efcf Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 01:30:19 +0000 Subject: [PATCH 2/5] Include message groups in the CLI command catalog --- tests/unit/test_cli_lazy_loading_regression.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 1ca9841f..e98b28b3 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -194,6 +194,9 @@ "mailsec": frozenset({ "coverage", "analyze", "onboarding", "message", "campaign", "sender", "action", "report", "rule", "banner", "connection", "tenant", "provider-quarantine", "release-request", + + "coverage", "analyze", "onboarding", "group", "message", "campaign", "sender", + "action", "report", "rule", "connection", "tenant", }), "lookup": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "note": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), From 071700ff619256db4b49d4f85d42c64ffe34bd0d Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 04:32:40 +0000 Subject: [PATCH 3/5] Preview frozen group dispositions with validated notes --- doc/cli/email-security.md | 3 +++ limacharlie/commands/mailsec.py | 9 +++++--- limacharlie/sdk/mailsec.py | 32 +++++++++++++++++++++++---- tests/unit/test_cli_mailsec_groups.py | 15 ++++++++++++- tests/unit/test_sdk_mailsec_groups.py | 26 ++++++++++++++++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index fd3f4e67..acf8e400 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -359,6 +359,7 @@ limacharlie mailsec group list --severity high --severity critical --disposition limacharlie mailsec group get limacharlie mailsec message list --group-id --severity high limacharlie mailsec group preview --action quarantine_message --reason "Incident review" +limacharlie mailsec group preview --action set_disposition --disposition benign --note "Reviewed every selected copy" limacharlie mailsec group status limacharlie mailsec group confirm --confirmation ``` @@ -376,3 +377,5 @@ Both preview and confirmation wait by default, with a bounded `--timeout` and error leaves the job intact. Inspect it with `group status`; execution resumes across worker handover. Failed or withheld recipient outcomes return a nonzero CLI exit code. Repeated confirmation adopts the same execution. + +Group classification uses the same complete preview and confirmation as remediation, with both `mailsec.act` and `mailsec.set`. Choose `malicious`, `spam`, `graymail`, `benign` or `simulation`, or use `--clear`. A disposition note is limited to1024 characters. The decision applies to copies frozen in that preview; later recipient copies retain their independent disposition. It preserves engine verdicts and severity. Provider overrides (`--force`, `--reason`, `--text`) do not apply to `set_disposition`. diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index e89cb7ae..c48e2488 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -973,16 +973,19 @@ def _group_wait_output(ctx, ms, job_id, preparing, timeout, poll_interval) -> No @message_groups.command("preview") @click.argument("group_id") -@click.option("--action", "action_name", required=True, type=click.Choice(BULK_ACTIONS)) +@click.option("--action", "action_name", required=True, type=click.Choice((*BULK_ACTIONS, "set_disposition"))) @click.option("--preview-id", default=None, help="UUID for retrying the identical frozen request; printed before preparation.") @click.option("--reason", default=None, help="Audited justification, frozen before confirmation.") @click.option("--text", default=None, help="Plain-text banner override, at most 512 characters, frozen before confirmation.") +@click.option("--disposition", type=click.Choice(("malicious", "spam", "graymail", "benign", "simulation")), help="Frozen analyst disposition; requires mailsec.act and mailsec.set.") +@click.option("--note", "disposition_note", default=None, help="Frozen disposition note, at most 1024 characters.") +@click.option("--clear", is_flag=True, help="Clear dispositions of the frozen copies.") @click.option("--force", is_flag=True, help="Explicitly override alert-only mode; frozen in this preview.") @click.option("--wait/--no-wait", default=True, help="Wait for a complete preview (default). Never executes it.") @click.option("--timeout", default=300, type=click.IntRange(1, 3600)) @click.option("--poll-interval", default=3, type=click.IntRange(1, 60)) @pass_context -def groups_preview(ctx, group_id, action_name, preview_id, reason, text, force, wait, timeout, poll_interval) -> None: +def groups_preview(ctx, group_id, action_name, preview_id, reason, text, force, disposition, disposition_note, clear, wait, timeout, poll_interval) -> None: """Prepare every recipient copy; confirmation is issued only after complete paging. Reuse --preview-id when retrying the same action/force/reason/text. Copies delivered @@ -991,7 +994,7 @@ def groups_preview(ctx, group_id, action_name, preview_id, reason, text, force, preview_id = preview_id or str(uuid.uuid4()) note(ctx, f"Preview identity: {preview_id}. Reuse it with identical parameters on retry.") ms = _get_mailsec(ctx) - status = ms.prepare_group_action(group_id, action_name, preview_id, force=force, reason=reason, text=text) + status = ms.prepare_group_action(group_id, action_name, preview_id, force=force, reason=reason, text=text, disposition=disposition, note=disposition_note, clear=clear) job_id = status.get("job", {}).get("job_id") if not job_id: _output(ctx, status) diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index b58db219..9b8321a8 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -1043,28 +1043,52 @@ def get_group(self, group_id: str) -> dict[str, Any]: def prepare_group_action( self, group_id: str, action: str, preview_id: str, *, force: bool = False, reason: str | None = None, text: str | None = None, + disposition: str | None = None, note: str | None = None, clear: bool = False, ) -> dict[str, Any]: """Freeze all recipient copies and parameters in a durable preview job. Args: group_id: Message-group identity. - action: Remediation action name. + action: Remediation action name, or set_disposition (requires mailsec.act and mailsec.set). preview_id: Caller-minted UUID; reuse it when retrying identical parameters. force: Explicit override of organization alert-only mode. reason: Audited reason, frozen before confirmation. text: Optional plain-text banner override, frozen before confirmation. + disposition: One of malicious, spam, graymail, benign or simulation. + note: Disposition note of at most 1024 UTF-8 characters. + clear: Clear every frozen copy's disposition instead of setting a value. Returns: dict: Job initially preparing; no token until every member is snapshotted. Raises: - ValueError: If either identity is malformed. - TypeError: If force is not a boolean. + ValueError: If an identity or the frozen disposition parameters are invalid. + TypeError: If force/clear is not a boolean or note is not text. """ + _check_force(force) + if not isinstance(clear, bool): + raise TypeError("clear must be a boolean") + if action == "set_disposition": + if force or reason or text: + raise ValueError("set_disposition does not accept provider remediation parameters") + if clear == (disposition is not None): + raise ValueError("provide one disposition or clear=True") + if disposition is not None and disposition not in {"malicious", "spam", "graymail", "benign", "simulation"}: + raise ValueError("invalid disposition") + if note is not None: + if not isinstance(note, str): + raise TypeError("note must be text") + note.encode("utf-8") + if len(note) > 1024: + raise ValueError("note must be at most 1024 characters") + elif disposition is not None or note is not None or clear: + raise ValueError("disposition parameters require set_disposition") body: dict[str, Any] = {"action": action, "preview_id": _group_job_identity(preview_id)} if _check_force(force): body["force"] = True - for key, value in (("reason", reason), ("text", text)): + if clear: + body["clear"] = True + for key, value in (("reason", reason), ("text", text), ("disposition", disposition), ("note", note)): if value is not None: body[key] = value return self._post(f"groups/{_group_identity(group_id)}/actions/preview", body) diff --git a/tests/unit/test_cli_mailsec_groups.py b/tests/unit/test_cli_mailsec_groups.py index 3f8d9bd7..8bf9b8f7 100644 --- a/tests/unit/test_cli_mailsec_groups.py +++ b/tests/unit/test_cli_mailsec_groups.py @@ -26,7 +26,7 @@ def invoke(*args, phase="ready", withheld=0): def test_preview_never_executes_and_freezes_fields(): result, ms = invoke("preview", GID, "--action", "banner_message", "--preview-id", JOB, "--text", "Review", "--reason", "Fixture", "--force") assert result.exit_code == 0, result.output - ms.prepare_group_action.assert_called_once_with(GID, "banner_message", JOB, force=True, reason="Fixture", text="Review") + ms.prepare_group_action.assert_called_once_with(GID, "banner_message", JOB, force=True, reason="Fixture", text="Review", disposition=None, note=None, clear=False) ms.confirm_group_action.assert_not_called() assert json.loads(result.stdout)["job"]["phase"] == "ready" assert JOB in result.stderr @@ -63,3 +63,16 @@ def test_group_filters_and_severity_choices(): result, ms = invoke("list", "--severity", "urgent") assert result.exit_code != 0 ms.list_groups.assert_not_called() + + +def test_group_disposition_preview_freezes_note_and_clear_without_execution(): + result, ms = invoke("preview", GID, "--action", "set_disposition", "--preview-id", JOB, "--disposition", "benign", "--note", "Reviewed synthetic copies", "--no-wait") + assert result.exit_code == 0, result.output + ms.prepare_group_action.assert_called_once_with(GID, "set_disposition", JOB, force=False, reason=None, text=None, disposition="benign", note="Reviewed synthetic copies", clear=False) + ms.confirm_group_action.assert_not_called() + result, ms = invoke("preview", GID, "--action", "set_disposition", "--preview-id", JOB, "--clear", "--no-wait") + assert result.exit_code == 0, result.output + assert ms.prepare_group_action.call_args.kwargs["clear"] is True + result, ms = invoke("preview", GID, "--action", "set_disposition", "--disposition", "true_positive") + assert result.exit_code != 0 + ms.prepare_group_action.assert_not_called() diff --git a/tests/unit/test_sdk_mailsec_groups.py b/tests/unit/test_sdk_mailsec_groups.py index 6fda27a5..82260969 100644 --- a/tests/unit/test_sdk_mailsec_groups.py +++ b/tests/unit/test_sdk_mailsec_groups.py @@ -102,3 +102,29 @@ def test_message_group_and_severity_filters_forward_and_narrow_search(client): ms.list_messages(severity=["critical"], q="fixture") with pytest.raises(ValueError): ms.list_messages(severity=["high", "critical"], q="fixture") + + +@pytest.mark.parametrize("disposition", ["malicious", "spam", "graymail", "benign", "simulation"]) +def test_group_disposition_uses_the_same_durable_preview(client, disposition): + ms, transport = client + ms.prepare_group_action(GID, "set_disposition", JOB, disposition=disposition, note="Reviewed synthetic copies") + assert json.loads(transport.request.call_args.kwargs["raw_body"]) == {"action": "set_disposition", "preview_id": JOB, "disposition": disposition, "note": "Reviewed synthetic copies"} + ms.prepare_group_action(GID, "set_disposition", JOB, clear=True) + assert json.loads(transport.request.call_args.kwargs["raw_body"]) == {"action": "set_disposition", "preview_id": JOB, "clear": True} + + +@pytest.mark.parametrize("action, params", [ + ("set_disposition", {}), + ("set_disposition", {"disposition": "true_positive"}), + ("set_disposition", {"disposition": "benign", "clear": True}), + ("set_disposition", {"disposition": "benign", "force": True}), + ("set_disposition", {"disposition": "benign", "reason": "provider override"}), + ("set_disposition", {"disposition": "benign", "note": "x" * 1025}), + ("set_disposition", {"disposition": "benign", "note": "\ud800"}), + ("trash_message", {"disposition": "benign"}), +]) +def test_invalid_group_disposition_refused_before_transport(client, action, params): + ms, transport = client + with pytest.raises(ValueError): + ms.prepare_group_action(GID, action, JOB, **params) + transport.request.assert_not_called() From 8952b951bb86b9978943c08ac243ba7b405bd000 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 23:26:16 +0000 Subject: [PATCH 4/5] Fix spacing in group disposition note limit Co-Authored-By: Claude Opus 5.5 (1M context) --- doc/cli/email-security.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index acf8e400..b5c8db2f 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -378,4 +378,4 @@ error leaves the job intact. Inspect it with `group status`; execution resumes across worker handover. Failed or withheld recipient outcomes return a nonzero CLI exit code. Repeated confirmation adopts the same execution. -Group classification uses the same complete preview and confirmation as remediation, with both `mailsec.act` and `mailsec.set`. Choose `malicious`, `spam`, `graymail`, `benign` or `simulation`, or use `--clear`. A disposition note is limited to1024 characters. The decision applies to copies frozen in that preview; later recipient copies retain their independent disposition. It preserves engine verdicts and severity. Provider overrides (`--force`, `--reason`, `--text`) do not apply to `set_disposition`. +Group classification uses the same complete preview and confirmation as remediation, with both `mailsec.act` and `mailsec.set`. Choose `malicious`, `spam`, `graymail`, `benign` or `simulation`, or use `--clear`. A disposition note is limited to 1024 characters. The decision applies to copies frozen in that preview; later recipient copies retain their independent disposition. It preserves engine verdicts and severity. Provider overrides (`--force`, `--reason`, `--text`) do not apply to `set_disposition`. From 550da4e6421a17385da8eb20ee01e99237e8a0b9 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 23:47:47 +0000 Subject: [PATCH 5/5] Resolve rebase onto banner, provider and disposition changes Co-Authored-By: Claude Opus 5.5 (1M context) --- limacharlie/commands/mailsec.py | 5 +--- limacharlie/sdk/mailsec.py | 27 ------------------- .../unit/test_cli_lazy_loading_regression.py | 4 +-- 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index c48e2488..d87e2473 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -1188,11 +1188,8 @@ def onboarding(ctx, provider, project_id, sa_email, topic, subscription) -> None @click.option("--cursor", default=None, help="Keyset token from a previous page.") @click.option("--limit", default=None, type=int, help="Page size.") @pass_context -def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id, state, - direction, lane, disposition, user_reported, no_user_reported, min_score, link_domain, - def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id, group_id, severity, state, - direction, lane, user_reported, no_user_reported, min_score, link_domain, + direction, lane, disposition, user_reported, no_user_reported, min_score, link_domain, attachment_sha256, q, since, until, cursor, limit) -> None: """The message index — the triage queue. diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 9b8321a8..ece1f8c6 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -210,33 +210,6 @@ def _add_scalar(pairs: list[tuple[str, str]], key: str, value: Any) -> None: -def _warn_banner_is_ignored(banner: str | None) -> None: - """Warn once per call that a caller-supplied banner goes nowhere. - - The banner used to travel on the request and was spliced into the - recipient's mailbox verbatim, so any caller holding ``mailsec.act`` chose - HTML that ran in someone else's mail client. It is now rendered by the - server from the organization's ``mailsec_policy`` record of type - ``banners``, escaped into a fixed template. - - The argument is kept and IGNORED rather than rejected, for one release: an - existing script keeps working and simply gets the organization's configured - banner, which is what it wanted. The warning is what stops that from being a - silent change — a field that quietly stops meaning anything is worse than - one that says so. - """ - if banner is None: - return - warnings.warn( - "mailsec: the `banner` argument is deprecated and ignored. The warning banner is " - "rendered by the server from the organization's mailsec_policy record of type " - "'banners' (its `text`), so that no caller can inject markup into a user's mailbox. " - "Set the wording there instead; this argument will be removed.", - DeprecationWarning, - stacklevel=3, - ) - - def _group_identity(value: str) -> str: if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value): raise ValueError("group_id must be a lowercase SHA-256 identity") diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index e98b28b3..d9892307 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -194,9 +194,7 @@ "mailsec": frozenset({ "coverage", "analyze", "onboarding", "message", "campaign", "sender", "action", "report", "rule", "banner", "connection", "tenant", "provider-quarantine", "release-request", - - "coverage", "analyze", "onboarding", "group", "message", "campaign", "sender", - "action", "report", "rule", "connection", "tenant", + "group", }), "lookup": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "note": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}),