From 1fe52a9508a63418fbe51698c47469189ce84267 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 23:40:36 +0000 Subject: [PATCH] Apply all message filters to grouped mail lists --- doc/cli/email-security.md | 15 ++ limacharlie/commands/mailsec.py | 45 ++++-- limacharlie/sdk/mailsec.py | 195 +++++++++++++++++++------- tests/unit/test_cli_mailsec_groups.py | 19 +++ tests/unit/test_sdk_mailsec_groups.py | 26 ++++ 5 files changed, 239 insertions(+), 61 deletions(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index 4471366c..b39aa043 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -375,6 +375,21 @@ 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. +Group listing accepts every message-list filter, with the same names and encoding: +`--search`/`--q`, `--mailbox`, `--sender-email`, `--sender-domain`/`--sender-root-domain`, +`--campaign-id`, `--group-id`, `--link-domain`, `--attachment-sha256`, repeatable +`--state`, `--direction`, `--verdict`, `--severity`, `--disposition` (including `none`), +`--min-score`, `--lane`, `--user-reported true|false`, `--since` and `--until`. +A group is returned only when **one recipient copy matches every active filter**. +Filtered order follows that group's newest matching copy; summary badges still +cover all copies. Search and lane have the same bounds/refusals as message lists. + +Continue through short or empty pages while `next_cursor` is present. Filtered +cursors pin a snapshot for 50 minutes; restart on expiry or changing filters. +`group_filter_too_broad` asks for narrower selectors rather than dropping filters. +Exact `matched_copies` counts are omitted to bound per-page cost. Group actions +always act on **all copies frozen at preview**, including copies outside list filters. + ```bash limacharlie mailsec group list --severity high --severity critical --disposition none limacharlie mailsec group get diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index 3297c184..c483ea48 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -1094,19 +1094,44 @@ def message_groups() -> None: @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("--since", default=None, help="Earliest matching-copy time (RFC3339 or unix seconds).") +@click.option("--until", default=None, help="Exclusive latest matching-copy 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.") +@click.option("--mailbox", default=None, help="Exact protected mailbox address.") +@click.option("--sender-email", default=None, help="Exact sender address.") +@click.option("--sender-domain", "--sender-root-domain", "sender_domain", default=None, help="Sender registrable root domain.") +@click.option("--campaign-id", default=None, help="Exact campaign identity.") +@click.option("--group-id", default=None, help="Exact message group identity.") +@click.option("--link-domain", default=None, help="Link registrable root domain.") +@click.option("--attachment-sha256", default=None, help="Attachment digest.") +@click.option("--state", multiple=True, help="Copy placement state (repeatable).") +@click.option("--direction", multiple=True, type=click.Choice(["inbound", "outbound", "internal"])) +@click.option("--min-score", default=None, type=click.IntRange(0,100), help="Minimum copy score.") +@click.option("--lane", default=None, type=click.Choice(["live", "backfill"])) +@click.option("--search", "--q", "q", default=None, help="Literal text search; requires --since or an indexed selector.") @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, - )) +def groups_list(ctx, verdict, severity, disposition, user_reported, all_groups, since, until, cursor, limit, mailbox, sender_email, sender_domain, campaign_id, group_id, link_domain, attachment_sha256, state, direction, min_score, lane, q) -> None: + """List groups whose one recipient copy matches every active filter. + + Order uses the newest matching copy. Continue short or empty pages while a + next_cursor is present. Summaries and group actions cover ALL copies, + including copies outside the filters; exact matched-copy counts are omitted. + """ + try: + result = _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, + mailbox=mailbox, sender_email=sender_email, sender_domain=sender_domain, + campaign_id=campaign_id, group_id=group_id, link_domain=link_domain, + attachment_sha256=attachment_sha256, state=list(state) or None, + direction=list(direction) or None, min_score=min_score, lane=lane, q=q, + ) + except (TypeError, ValueError) as error: + raise click.ClickException(str(error)) from error + _output(ctx, result) @message_groups.command("get") diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 31771af1..cf77ce18 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -365,6 +365,69 @@ def get_coverage( # Messages # ------------------------------------------------------------------ + @staticmethod + def _message_filter_pairs( + *, + 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, + campaign_id: str | None = None, + state: list[str] | None = None, + direction: list[str] | None = None, + lane: str | None = None, + user_reported: bool | None = None, + min_score: int | None = None, + link_domain: str | None = None, + attachment_sha256: str | None = None, + q: str | None = None, + since: str | None = None, + until: str | None = None, + cursor: str | None = None, + limit: int | None = None, + disposition: list[str] | None = None, + ) -> list[tuple[str, str]]: + if q is not None: + q = q.strip() or None + if q: + if len(q) > 512: + raise ValueError("q must be at most 512 code points") + 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()) + 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, " + "group_id, link_domain, attachment_sha256, or exactly one verdict/severity" + ) + pairs: list[tuple[str, str]] = [] + _add_pairs(pairs, "disposition", disposition) + _add_pairs(pairs, "verdict", verdict) + _add_pairs(pairs, "severity", severity) + _add_pairs(pairs, "state", state) + _add_pairs(pairs, "direction", direction) + for key, val in ( + ("mailbox", mailbox), + ("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), + ("q", q), + ("since", since), + ("until", until), + ("cursor", cursor), + ("limit", limit), + ("user_reported", user_reported), + ("lane", lane), + ): + _add_scalar(pairs, key, val) + return pairs + def list_messages( self, *, @@ -436,43 +499,28 @@ def list_messages( """ if disposition is not None and disposition not in (*DISPOSITIONS, "none"): raise ValueError("invalid disposition filter") - if q is not None: - q = q.strip() or None - if q: - if len(q) > 512: - raise ValueError("q must be at most 512 code points") - 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()) - 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, " - "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 ( - ("mailbox", mailbox), - ("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), - ("q", q), - ("since", since), - ("until", until), - ("cursor", cursor), - ("limit", limit), - ("user_reported", user_reported), - ("lane", lane), - ("disposition", disposition), - ): - _add_scalar(pairs, key, val) + pairs = self._message_filter_pairs( + disposition=[disposition] if disposition is not None else None, + verdict=verdict, + severity=severity, + group_id=group_id, + mailbox=mailbox, + sender_email=sender_email, + sender_domain=sender_domain, + campaign_id=campaign_id, + state=state, + direction=direction, + lane=lane, + user_reported=user_reported, + min_score=min_score, + link_domain=link_domain, + attachment_sha256=attachment_sha256, + q=q, + since=since, + until=until, + cursor=cursor, + limit=limit, + ) return self._get("messages", pairs) def get_message(self, msg_uuid: str) -> dict[str, Any]: @@ -1026,33 +1074,78 @@ def list_groups( 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, + mailbox: str | None = None, sender_email: str | None = None, + sender_domain: str | None = None, campaign_id: str | None = None, + group_id: str | None = None, link_domain: str | None = None, + attachment_sha256: str | None = None, state: list[str] | None = None, + direction: list[str] | None = None, min_score: int | None = None, + lane: str | None = None, q: str | None = None, ) -> dict[str, Any]: - """List the flagged message-group queue, ordered by last seen. + """List groups whose ONE recipient copy matches ALL active filters. + + Filters use the same encoding and meanings as :meth:`list_messages`: + alternatives within a key, AND across keys on one copy. Filtered order + is the newest matching copy. Returned summaries and group actions always + cover the whole group; exact matched-copy counts are omitted to bound cost. + Continue through short/empty pages until next_cursor is empty. Cursors + pin a snapshot for 50 minutes; restart after expiry or changing filters. Args: verdict: Repeatable engine verdict filter. - severity: Repeatable severity filter. - disposition: Repeatable analyst disposition filter, including none. + severity: Repeatable rule-impact filter. + disposition: Repeatable analyst disposition, 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. + since: Inclusive matching-copy time, RFC3339 or unix seconds. + until: Exclusive matching-copy time. + cursor: Opaque filter-bound snapshot cursor. + limit: Maximum returned groups per page. + mailbox: Exact protected mailbox address. + sender_email: Exact sender address. + sender_domain: Sender registrable domain (sender_root_domain on the wire). + campaign_id: Exact campaign identity. + group_id: Exact message-group identity. + link_domain: Link registrable domain. + attachment_sha256: Attachment digest. + state: Repeatable copy placement state. + direction: Repeatable inbound, outbound or internal direction. + min_score: Minimum copy score. + lane: Live or backfill, with the same supported combinations as messages. + q: Literal case-insensitive subject/sender substring; requires a bounded-walk companion. Returns: - dict: Groups, next_cursor and materialized as_of timestamps. + dict: Whole-group summaries, next_cursor and as_of timestamps. + + Raises: + TypeError: If all_groups is not boolean. + ValueError: If non-empty q is too long or lacks a bounded-walk companion. """ - 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") + pairs = self._message_filter_pairs( + verdict=verdict, + severity=severity, + group_id=group_id, + mailbox=mailbox, + sender_email=sender_email, + sender_domain=sender_domain, + campaign_id=campaign_id, + state=state, + direction=direction, + lane=lane, + user_reported=user_reported, + min_score=min_score, + link_domain=link_domain, + attachment_sha256=attachment_sha256, + q=q, + since=since, + until=until, + cursor=cursor, + limit=limit, + disposition=disposition, + ) 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]: diff --git a/tests/unit/test_cli_mailsec_groups.py b/tests/unit/test_cli_mailsec_groups.py index 8bf9b8f7..11706e06 100644 --- a/tests/unit/test_cli_mailsec_groups.py +++ b/tests/unit/test_cli_mailsec_groups.py @@ -76,3 +76,22 @@ def test_group_disposition_preview_freezes_note_and_clear_without_execution(): result, ms = invoke("preview", GID, "--action", "set_disposition", "--disposition", "true_positive") assert result.exit_code != 0 ms.prepare_group_action.assert_not_called() + + +def test_group_list_forwards_every_copy_filter(): + result, ms = invoke("list", "--mailbox", "copy@example.invalid", "--sender-email", "sender@example.invalid", + "--sender-root-domain", "example.invalid", "--campaign-id", JOB, "--group-id", GID, + "--link-domain", "linked.invalid", "--attachment-sha256", "b" * 64, + "--state", "delivered", "--state", "quarantined", "--direction", "inbound", + "--direction", "internal", "--min-score", "70", "--lane", "live", "--q", "literal 50%", + "--since", "2026-10-01T00:00:00Z", "--until", "2026-10-02T00:00:00Z", + "--verdict", "malicious", "--severity", "critical", "--disposition", "none", + "--user-reported", "false", "--all", "--cursor", "opaque", "--limit", "5") + assert result.exit_code == 0, result.output + ms.list_groups.assert_called_once_with( + verdict=["malicious"], severity=["critical"], disposition=["none"], user_reported=False, all_groups=True, + since="2026-10-01T00:00:00Z", until="2026-10-02T00:00:00Z", cursor="opaque", limit=5, + mailbox="copy@example.invalid", sender_email="sender@example.invalid", sender_domain="example.invalid", + campaign_id=JOB, group_id=GID, link_domain="linked.invalid", attachment_sha256="b" * 64, + state=["delivered", "quarantined"], direction=["inbound", "internal"], min_score=70, lane="live", q="literal 50%", + ) diff --git a/tests/unit/test_sdk_mailsec_groups.py b/tests/unit/test_sdk_mailsec_groups.py index 82260969..24e9d1bc 100644 --- a/tests/unit/test_sdk_mailsec_groups.py +++ b/tests/unit/test_sdk_mailsec_groups.py @@ -128,3 +128,29 @@ def test_invalid_group_disposition_refused_before_transport(client, action, para with pytest.raises(ValueError): ms.prepare_group_action(GID, action, JOB, **params) transport.request.assert_not_called() + + +def test_group_and_message_filter_wire_parity(client): + ms, transport = client + filters = dict(verdict=["malicious", "suspicious"], severity=["high", "critical"], + group_id=GID, mailbox="copy@example.invalid", sender_email="sender@example.invalid", + sender_domain="example.invalid", campaign_id=JOB, state=["delivered", "quarantined"], + direction=["inbound", "internal"], lane="live", user_reported=False, min_score=70, + link_domain="linked.invalid", attachment_sha256="b" * 64, q=" literal 50% ", + since="2026-10-01T00:00:00Z", until="2026-10-02T00:00:00Z", cursor="opaque", limit=5) + ms.list_messages(**filters) + message_pairs = transport.request.call_args.kwargs["query_params"] + ms.list_groups(**filters, disposition=["malicious", "none"], all_groups=True) + group_pairs = transport.request.call_args.kwargs["query_params"] + assert [(k, v) for k, v in group_pairs if k not in ("disposition", "all")] == message_pairs + assert ("disposition", "none") in group_pairs + assert ("q", "literal 50%") in group_pairs + + +@pytest.mark.parametrize("q,kwargs", [("x" * 513, {"since": "2026-10-01"}), ("needle", {}), + ("needle", {"until": "2026-10-02"})]) +def test_group_search_refuses_unbounded_or_oversized_input(client, q, kwargs): + ms, transport = client + with pytest.raises(ValueError): + ms.list_groups(q=q, **kwargs) + transport.request.assert_not_called()