文件预览

inference_overrides.py

查看 Outlook Mcp 技能包中的文件内容。

文件内容

src/outlook_mcp/tools/inference_overrides.py

"""Focused Inbox per-sender override CRUD tools."""

from __future__ import annotations

from typing import Any

from outlook_mcp.config import Config
from outlook_mcp.permissions import CATEGORY_MAIL_TRIAGE, check_permission
from outlook_mcp.validation import sanitize_output, validate_email, validate_graph_id

_VALID_CLASSIFY = ("focused", "other")


def _classify_pairs():
    """Single source of truth for (string, enum) classify_as pairs."""
    from msgraph.generated.models.inference_classification_type import (
        InferenceClassificationType,
    )

    return (
        ("focused", InferenceClassificationType.Focused),
        ("other", InferenceClassificationType.Other),
    )


def _enum_to_str(enum_value: Any) -> str:
    """Map an InferenceClassificationType enum back to 'focused' / 'other'."""
    name = getattr(enum_value, "name", None)
    by_name = {enum.name: s for s, enum in _classify_pairs()}
    return by_name.get(name, "other")


async def list_inbox_overrides(graph_client: Any) -> dict:
    """List Focused Inbox per-sender override rules.

    GET /me/inferenceClassification/overrides. Returns the full list — no
    pagination cursor (override counts are small per user). Names and email
    addresses are passed through `sanitize_output` to scrub control chars.
    """
    response = await graph_client.me.inference_classification.overrides.get()
    values = getattr(response, "value", None) or []

    overrides = []
    for ov in values:
        email = getattr(ov, "sender_email_address", None)
        address = getattr(email, "address", None) or "" if email else ""
        name = getattr(email, "name", None) or "" if email else ""
        overrides.append(
            {
                "id": getattr(ov, "id", "") or "",
                "sender_email": sanitize_output(address),
                "sender_name": sanitize_output(name),
                "classify_as": _enum_to_str(getattr(ov, "classify_as", None)),
            }
        )

    return {"overrides": overrides, "count": len(overrides)}


async def set_inbox_override(
    graph_client: Any,
    sender_email: str,
    classify_as: str,
    *,
    config: Config,
) -> dict:
    """Upsert a Focused Inbox override for a sender.

    Graph rejects a POST when an override for that sender already exists, so
    this lists existing overrides first and PATCHes when a case-insensitive
    sender match is found.

    Race: a concurrent delete between list and PATCH surfaces as a 404 from
    Graph; caller can retry.
    """
    check_permission(config, CATEGORY_MAIL_TRIAGE, "outlook_set_inbox_override")

    sender_email = validate_email(sender_email)
    if classify_as not in _VALID_CLASSIFY:
        raise ValueError(f"classify_as must be one of {_VALID_CLASSIFY!r}, got {classify_as!r}")

    from msgraph.generated.models.email_address import EmailAddress
    from msgraph.generated.models.inference_classification_override import (
        InferenceClassificationOverride,
    )

    classify_map = dict(_classify_pairs())
    target_enum = classify_map[classify_as]

    overrides_builder = graph_client.me.inference_classification.overrides
    response = await overrides_builder.get()
    existing_values = getattr(response, "value", None) or []
    needle = sender_email.lower()

    existing = None
    # Graph contract: at most one override per sender; first match wins.
    for ov in existing_values:
        email = getattr(ov, "sender_email_address", None)
        address = getattr(email, "address", None) if email else None
        if address and address.lower() == needle:
            existing = ov
            break

    if existing is not None:
        patch_body = InferenceClassificationOverride()
        patch_body.classify_as = target_enum
        item_builder = overrides_builder.by_inference_classification_override_id(existing.id)
        await item_builder.patch(patch_body)
        return {
            "status": "updated",
            "id": existing.id,
            "sender_email": sender_email,
            "classify_as": classify_as,
        }

    body = InferenceClassificationOverride()
    body.classify_as = target_enum
    body.sender_email_address = EmailAddress(address=sender_email)

    created = await overrides_builder.post(body)
    new_id = getattr(created, "id", "") or ""
    return {
        "status": "created",
        "id": new_id,
        "sender_email": sender_email,
        "classify_as": classify_as,
    }


async def delete_inbox_override(
    graph_client: Any,
    override_id: str,
    *,
    config: Config,
) -> dict:
    """Delete a Focused Inbox override by ID."""
    check_permission(config, CATEGORY_MAIL_TRIAGE, "outlook_delete_inbox_override")
    override_id = validate_graph_id(override_id)

    overrides_builder = graph_client.me.inference_classification.overrides
    item_builder = overrides_builder.by_inference_classification_override_id(override_id)
    await item_builder.delete()
    return {"status": "deleted", "id": override_id}