Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions doc/cli/email-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,26 @@ initial decision and completion instants, plus integer millisecond intervals:
An absent interval is unknown; a measured zero is zero. `clock_skew: true` reports
that a negative interval was clamped to zero. The sender's Date header is not used
to calculate these intervals.

## Microsoft provider quarantine visibility

```bash
limacharlie mailsec provider-quarantine list --status quarantined --output yaml
limacharlie mailsec release-request list --status requested --output yaml
```

Both commands require `mailsec.get` and return `coverage` alongside rows and an
opaque `next_cursor`. Keep filters unchanged when passing `--cursor`. Both accept
`--connection`, `--since`, `--until`, and `--limit` (1–1000).

Provider delivery statuses are `quarantined`, `filteredAsSpam`, and `failed`.
A failed delivery is not a quarantine verdict. Release activity statuses are
`requested`, `released`, and `denied`. These commands observe Microsoft activity;
they do not approve or perform release.

Optional Microsoft application permissions are `ExchangeMessageTrace.Read.All`
in Microsoft Graph and `ActivityFeed.Read` in Office 365 Management APIs, with
admin consent. Message trace also requires Microsoft's documented Exchange
service-principal prerequisite; activity requires unified audit logging. Missing
grants report `not_granted`. Pending, stale or error coverage must be checked
before treating an empty list as evidence that no messages were blocked.
50 changes: 50 additions & 0 deletions limacharlie/commands/mailsec.py
Original file line number Diff line number Diff line change
Expand Up @@ -1775,3 +1775,53 @@ def tenant_purge(ctx, confirm, reason) -> None:
register_explain("mailsec.rule.backtest", _EXPLAIN_RULE_BACKTEST)
register_explain("mailsec.connection.test", _EXPLAIN_CONNECTION_TEST)
register_explain("mailsec.tenant.purge", _EXPLAIN_TENANT_PURGE)


@group.group("provider-quarantine")
def provider_quarantine_group() -> None:
"""Microsoft provider delivery filtering and quarantine observations."""


@provider_quarantine_group.command("list")
@click.option("--connection", default=None, help="Connection record name.")
@click.option("--status", type=click.Choice(["quarantined", "filteredAsSpam", "failed"]), default=None)
@click.option("--since", default=None, help="Inclusive timestamp, RFC3339 or Unix seconds.")
@click.option("--until", default=None, help="Exclusive timestamp, RFC3339 or Unix seconds.")
@click.option("--cursor", default=None, help="Opaque cursor; keep filters unchanged.")
@click.option("--limit", type=click.IntRange(1, 1000), default=None)
@pass_context
def provider_quarantine_list(ctx, connection, status, since, until, cursor, limit) -> None:
"""List provider observations with independent permission and freshness coverage.

Failed means delivery failed, not quarantined. Unavailable coverage is not
evidence that Microsoft blocked no messages.
"""
_output(ctx, _get_mailsec(ctx).list_provider_quarantine(
connection=connection, status=status, since=since, until=until,
cursor=cursor, limit=limit,
))


@group.group("release-request")
def release_request_group() -> None:
"""Microsoft hosted-quarantine release requests and release activity."""


@release_request_group.command("list")
@click.option("--connection", default=None, help="Connection record name.")
@click.option("--status", type=click.Choice(["requested", "released", "denied"]), default=None)
@click.option("--since", default=None, help="Inclusive timestamp, RFC3339 or Unix seconds.")
@click.option("--until", default=None, help="Exclusive timestamp, RFC3339 or Unix seconds.")
@click.option("--cursor", default=None, help="Opaque cursor; keep filters unchanged.")
@click.option("--limit", type=click.IntRange(1, 1000), default=None)
@pass_context
def release_request_list(ctx, connection, status, since, until, cursor, limit) -> None:
"""Observe release requests and releases/denials; this command approves nothing."""
_output(ctx, _get_mailsec(ctx).list_release_requests(
connection=connection, status=status, since=since, until=until,
cursor=cursor, limit=limit,
))


register_explain("mailsec.provider-quarantine.list", "Microsoft provider delivery observations. Inspect coverage before interpreting an empty list. Requires optional ExchangeMessageTrace.Read.All consent.")
register_explain("mailsec.release-request.list", "Microsoft hosted-quarantine release activity. This command observes requests and never approves a release. Requires optional ActivityFeed.Read consent and unified audit logging.")
1 change: 1 addition & 0 deletions limacharlie/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@
"description": "Email security: provider onboarding, mailbox coverage, mail triage, verdicts, campaigns, remediation, and reports",
"commands": [
"mailsec coverage",
"mailsec provider-quarantine list", "mailsec release-request list",
"mailsec message list", "mailsec message get", "mailsec message eml",
"mailsec message similar", "mailsec message action",
"mailsec message revise", "mailsec message revisions",
Expand Down
53 changes: 53 additions & 0 deletions limacharlie/sdk/mailsec.py
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,59 @@ def analyze(
# Abuse-mailbox report queue
# ------------------------------------------------------------------

def list_provider_quarantine(
self, *, connection: str | None = None, status: str | None = None,
since: str | int | None = None, until: str | int | None = None,
cursor: str | None = None, limit: int | None = None,
) -> dict[str, Any]:
"""List Microsoft provider delivery observations and coverage.

Args:
connection: Optional connection record name.
status: quarantined, filteredAsSpam or failed (case sensitive).
since: Inclusive provider timestamp, RFC3339 or Unix seconds.
until: Exclusive provider timestamp, RFC3339 or Unix seconds.
cursor: Opaque cursor; keep filters unchanged between pages.
limit: Page size, maximum 1000.

Returns:
dict: provider_quarantine, next_cursor and coverage. Failed means
delivery failed, not hosted quarantine. An empty list with
unavailable coverage does not establish that nothing was blocked.
"""
pairs: list[tuple[str, str]] = []
for key, val in (("connection", connection), ("status", status),
("since", since), ("until", until),
("cursor", cursor), ("limit", limit)):
_add_scalar(pairs, key, val)
return self._get("provider-quarantine", pairs)

def list_release_requests(
self, *, connection: str | None = None, status: str | None = None,
since: str | int | None = None, until: str | int | None = None,
cursor: str | None = None, limit: int | None = None,
) -> dict[str, Any]:
"""List Microsoft quarantine release activity and coverage.

Args:
connection: Optional connection record name.
status: requested, released or denied (lowercase).
since: Inclusive provider timestamp, RFC3339 or Unix seconds.
until: Exclusive provider timestamp, RFC3339 or Unix seconds.
cursor: Opaque cursor; keep filters unchanged between pages.
limit: Page size, maximum 1000.

Returns:
dict: release_requests, next_cursor and coverage. This method
observes requests and release/denial history; it approves nothing.
"""
pairs: list[tuple[str, str]] = []
for key, val in (("connection", connection), ("status", status),
("since", since), ("until", until),
("cursor", cursor), ("limit", limit)):
_add_scalar(pairs, key, val)
return self._get("release-requests", pairs)

def list_reports(
self,
*,
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_cli_lazy_loading_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
"logging": frozenset({"create", "delete", "get", "list"}),
"mailsec": frozenset({
"coverage", "analyze", "onboarding", "message", "campaign", "sender",
"action", "report", "rule", "banner", "connection", "tenant",
"action", "report", "rule", "banner", "connection", "tenant", "provider-quarantine", "release-request",
}),
"lookup": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}),
"note": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}),
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_cli_mailsec.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,27 @@ def test_force_help_uses_the_agreed_wording(self):
result = CliRunner().invoke(cli, ["mailsec", *path, "--help"], terminal_width=10000)
assert result.exit_code == 0, result.output
assert expected in " ".join(result.output.split()), path


def test_provider_visibility_commands_preserve_coverage_and_validate_status():
for command, method, status in [
("provider-quarantine", "list_provider_quarantine", "failed"),
("release-request", "list_release_requests", "requested"),
]:
with (
patch("limacharlie.commands.mailsec.Client"),
patch("limacharlie.commands.mailsec.Organization"),
patch("limacharlie.commands.mailsec.Mailsec") as factory,
):
api = MagicMock()
factory.return_value = api
getattr(api, method).return_value = {"coverage": [{"state": "not_granted"}], "next_cursor": "opaque"}
args = ["--oid", "11111111-2222-3333-4444-555555555555", "--output", "json", "mailsec", command, "list"]
result = CliRunner().invoke(cli, args + ["--status", status, "--connection", "m365", "--cursor", "opaque+/=", "--limit", "25"])
assert result.exit_code == 0, result.output
assert json.loads(result.output)["coverage"][0]["state"] == "not_granted"
getattr(api, method).assert_called_once_with(connection="m365", status=status, since=None, until=None, cursor="opaque+/=", limit=25)
result = CliRunner().invoke(cli, args + ["--status", "unknown"])
assert result.exit_code == 2
result = CliRunner().invoke(cli, args + ["--limit", "1001"])
assert result.exit_code == 2
14 changes: 14 additions & 0 deletions tests/unit/test_sdk_mailsec.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,3 +844,17 @@ def test_the_bound_itself_is_accepted(self, ms, mock_org):
ms.purge_tenant("tok", reason="x" * 1024)
_, kwargs = mock_org.client.request.call_args
assert ("reason", "x" * 1024) in kwargs["query_params"]

class TestProviderVisibility:
@pytest.mark.parametrize("method,path,status", [
("list_provider_quarantine", "provider-quarantine", "filteredAsSpam"),
("list_release_requests", "release-requests", "denied"),
])
def test_tenant_filters_and_opaque_cursor_reach_the_transport(self, ms, mock_org, method, path, status):
response = {path.replace("-", "_"): [], "coverage": [{"state": "not_granted"}], "next_cursor": "next"}
mock_org.client.request.return_value = response
result = getattr(ms, method)(connection="m365", status=status, since="2026-09-01T00:00:00Z", until="2026-09-02T00:00:00Z", cursor="opaque+/=", limit=25)
url, pairs = _get_call(mock_org)
assert url == f"mailsec/{OID}/{path}"
assert dict(pairs) == {"connection": "m365", "status": status, "since": "2026-09-01T00:00:00Z", "until": "2026-09-02T00:00:00Z", "cursor": "opaque+/=", "limit": "25"}
assert result == response
Loading