Merge pull request #183 from christianlouis/codex/m12-m365-safe-backfills
feat: add M365 safe backfill windows
This commit is contained in:
@@ -12,7 +12,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -446,6 +446,7 @@ def _fetch_response(source: MailSource, results: Dict[str, Any]) -> Dict[str, An
|
|||||||
"error_count": len(results.get("errors", [])),
|
"error_count": len(results.get("errors", [])),
|
||||||
"target_mailbox": results.get("target_mailbox"),
|
"target_mailbox": results.get("target_mailbox"),
|
||||||
"target_folder": results.get("target_folder"),
|
"target_folder": results.get("target_folder"),
|
||||||
|
"search_window_days": results.get("search_window_days"),
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +488,7 @@ def _fetch_gmail_source(source: MailSource, db: Session) -> Dict[str, Any]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _fetch_m365_source(source: MailSource, db: Session) -> Dict[str, Any]:
|
def _fetch_m365_source(source: MailSource, db: Session, days: int = 7) -> Dict[str, Any]:
|
||||||
"""Run one Microsoft 365 Graph import and persist source/import metadata."""
|
"""Run one Microsoft 365 Graph import and persist source/import metadata."""
|
||||||
if not source.m365_access_token:
|
if not source.m365_access_token:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -510,7 +511,7 @@ def _fetch_m365_source(source: MailSource, db: Session) -> Dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
started_at = datetime.utcnow()
|
started_at = datetime.utcnow()
|
||||||
results = client.fetch_reports()
|
results = client.fetch_reports(days=days)
|
||||||
|
|
||||||
if results.get("new_ingested_ids"):
|
if results.get("new_ingested_ids"):
|
||||||
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
|
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
|
||||||
@@ -551,7 +552,7 @@ def _fetch_source(source: MailSource, db: Session, days: int) -> Dict[str, Any]:
|
|||||||
if source.method == "GMAIL_API":
|
if source.method == "GMAIL_API":
|
||||||
return _fetch_gmail_source(source, db)
|
return _fetch_gmail_source(source, db)
|
||||||
if source.method == "M365_GRAPH":
|
if source.method == "M365_GRAPH":
|
||||||
return _fetch_m365_source(source, db)
|
return _fetch_m365_source(source, db, days=days)
|
||||||
if source.method == "IMAP":
|
if source.method == "IMAP":
|
||||||
return _fetch_imap_source(source, db, days)
|
return _fetch_imap_source(source, db, days)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1113,6 +1114,7 @@ async def m365_oauth_callback_post(
|
|||||||
@router.post("/{source_id}/m365/fetch", response_model=Dict[str, Any])
|
@router.post("/{source_id}/m365/fetch", response_model=Dict[str, Any])
|
||||||
async def m365_fetch_reports(
|
async def m365_fetch_reports(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
|
days: int = Query(7, ge=1, le=365, title="Number of days to fetch"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -1125,7 +1127,7 @@ async def m365_fetch_reports(
|
|||||||
detail="This endpoint is only available for M365_GRAPH sources.",
|
detail="This endpoint is only available for M365_GRAPH sources.",
|
||||||
)
|
)
|
||||||
|
|
||||||
results = _fetch_m365_source(source, db)
|
results = _fetch_m365_source(source, db, days=days)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Microsoft 365 fetch for source id=%d: processed=%d reports_found=%d",
|
"Microsoft 365 fetch for source id=%d: processed=%d reports_found=%d",
|
||||||
int(source_id),
|
int(source_id),
|
||||||
|
|||||||
+5
-5
@@ -193,7 +193,7 @@ def _poll_single_m365_source(source: MailSource) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
started_at = datetime.utcnow()
|
started_at = datetime.utcnow()
|
||||||
results = client.fetch_reports()
|
results = client.fetch_reports(days=7)
|
||||||
if src:
|
if src:
|
||||||
if results.get("new_ingested_ids"):
|
if results.get("new_ingested_ids"):
|
||||||
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
|
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
|
||||||
@@ -756,7 +756,7 @@ def _trigger_poll_gmail_source(source: MailSource, db) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _trigger_poll_m365_source(source: MailSource, db) -> dict:
|
def _trigger_poll_m365_source(source: MailSource, db, days: int = 7) -> dict:
|
||||||
"""Poll a single M365_GRAPH source and return a result dict for the API response."""
|
"""Poll a single M365_GRAPH source and return a result dict for the API response."""
|
||||||
global last_check_time # pylint: disable=global-statement
|
global last_check_time # pylint: disable=global-statement
|
||||||
|
|
||||||
@@ -774,7 +774,7 @@ def _trigger_poll_m365_source(source: MailSource, db) -> dict:
|
|||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
started_at = datetime.utcnow()
|
started_at = datetime.utcnow()
|
||||||
results = graph_client.fetch_reports()
|
results = graph_client.fetch_reports(days=days)
|
||||||
last_check_time = datetime.now()
|
last_check_time = datetime.now()
|
||||||
|
|
||||||
if results.get("new_ingested_ids"):
|
if results.get("new_ingested_ids"):
|
||||||
@@ -832,7 +832,7 @@ def _poll_source_for_trigger(source: MailSource, db, days: int = 7) -> dict: #
|
|||||||
"reason": "Microsoft 365 account not yet authorised",
|
"reason": "Microsoft 365 account not yet authorised",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
return _trigger_poll_m365_source(source, db)
|
return _trigger_poll_m365_source(source, db, days=days)
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.error("Error polling Microsoft 365 source id=%d: %s", source.id, str(e))
|
logger.error("Error polling Microsoft 365 source id=%d: %s", source.id, str(e))
|
||||||
return {
|
return {
|
||||||
@@ -881,7 +881,7 @@ def _poll_enabled_sources_for_trigger(days: int) -> list[dict]:
|
|||||||
@app.post("/api/v1/admin/trigger-poll")
|
@app.post("/api/v1/admin/trigger-poll")
|
||||||
async def trigger_imap_poll(
|
async def trigger_imap_poll(
|
||||||
auth: dict = Depends(require_admin_auth),
|
auth: dict = Depends(require_admin_auth),
|
||||||
days: int = Query(7, ge=1, le=365, title="Number of days to fetch for IMAP sources"),
|
days: int = Query(7, ge=1, le=365, title="Number of days to fetch for mail sources"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Manually trigger IMAP polling for all enabled mail sources (admin only).
|
Manually trigger IMAP polling for all enabled mail sources (admin only).
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
from urllib.parse import quote, urlencode
|
from urllib.parse import quote, urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -25,6 +27,9 @@ M365_SCOPES = [
|
|||||||
|
|
||||||
_PAGE_SIZE = 100
|
_PAGE_SIZE = 100
|
||||||
_MAX_FOLDER_DEPTH = 5
|
_MAX_FOLDER_DEPTH = 5
|
||||||
|
_MAX_GRAPH_RETRIES = 3
|
||||||
|
_MAX_RETRY_DELAY_SECONDS = 30
|
||||||
|
_RETRYABLE_STATUS_CODES = {429, 503, 504}
|
||||||
_DMARC_SUBJECT_TERMS = (
|
_DMARC_SUBJECT_TERMS = (
|
||||||
"dmarc",
|
"dmarc",
|
||||||
"aggregate report",
|
"aggregate report",
|
||||||
@@ -65,6 +70,7 @@ class MicrosoftGraphClient:
|
|||||||
folder_id: Optional[str] = None,
|
folder_id: Optional[str] = None,
|
||||||
already_ingested_ids: Optional[List[str]] = None,
|
already_ingested_ids: Optional[List[str]] = None,
|
||||||
db: Any = None,
|
db: Any = None,
|
||||||
|
sleep: Optional[Callable[[float], None]] = None,
|
||||||
):
|
):
|
||||||
self.tenant_id = tenant_id or "common"
|
self.tenant_id = tenant_id or "common"
|
||||||
self.client_id = client_id
|
self.client_id = client_id
|
||||||
@@ -77,6 +83,7 @@ class MicrosoftGraphClient:
|
|||||||
self.already_ingested_ids: List[str] = list(already_ingested_ids or [])
|
self.already_ingested_ids: List[str] = list(already_ingested_ids or [])
|
||||||
self.report_store = ReportStore.get_instance()
|
self.report_store = ReportStore.get_instance()
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self._sleep = sleep or time.sleep
|
||||||
self._refreshed_tokens: Optional[Dict[str, str]] = None
|
self._refreshed_tokens: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
def get_refreshed_tokens(self) -> Optional[Dict[str, str]]:
|
def get_refreshed_tokens(self) -> Optional[Dict[str, str]]:
|
||||||
@@ -227,8 +234,9 @@ class MicrosoftGraphClient:
|
|||||||
url = data.get("@odata.nextLink")
|
url = data.get("@odata.nextLink")
|
||||||
params = None
|
params = None
|
||||||
|
|
||||||
def fetch_reports(self) -> Dict[str, Any]:
|
def fetch_reports(self, days: int = 7) -> Dict[str, Any]:
|
||||||
"""Fetch and ingest DMARC report attachments from Microsoft Graph."""
|
"""Fetch and ingest DMARC report attachments from Microsoft Graph."""
|
||||||
|
safe_days = max(1, min(int(days or 7), 365))
|
||||||
stats: Dict[str, Any] = {
|
stats: Dict[str, Any] = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"processed": 0,
|
"processed": 0,
|
||||||
@@ -242,10 +250,11 @@ class MicrosoftGraphClient:
|
|||||||
"details": [],
|
"details": [],
|
||||||
"target_mailbox": self._target_mailbox_label(),
|
"target_mailbox": self._target_mailbox_label(),
|
||||||
"target_folder": self._target_folder_label(),
|
"target_folder": self._target_folder_label(),
|
||||||
|
"search_window_days": safe_days,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
messages = self._list_dmarc_messages()
|
messages = self._list_dmarc_messages(days=safe_days)
|
||||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
logger.error("Microsoft Graph: failed to list messages: %s", exc)
|
logger.error("Microsoft Graph: failed to list messages: %s", exc)
|
||||||
return {**stats, "success": False, "error": str(exc), "errors": [str(exc)]}
|
return {**stats, "success": False, "error": str(exc), "errors": [str(exc)]}
|
||||||
@@ -324,14 +333,42 @@ class MicrosoftGraphClient:
|
|||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
url = path_or_url if path_or_url.startswith("http") else f"{GRAPH_BASE_URL}{path_or_url}"
|
url = path_or_url if path_or_url.startswith("http") else f"{GRAPH_BASE_URL}{path_or_url}"
|
||||||
resp = httpx.request(method, url, headers=self._headers(), params=params, timeout=30)
|
resp: Optional[httpx.Response] = None
|
||||||
if resp.status_code == 401 and self.refresh_token:
|
|
||||||
self._refresh_access_token()
|
for attempt in range(_MAX_GRAPH_RETRIES + 1):
|
||||||
resp = httpx.request(method, url, headers=self._headers(), params=params, timeout=30)
|
resp = httpx.request(method, url, headers=self._headers(), params=params, timeout=30)
|
||||||
|
if resp.status_code == 401 and self.refresh_token:
|
||||||
|
self._refresh_access_token()
|
||||||
|
resp = httpx.request(method, url, headers=self._headers(), params=params, timeout=30)
|
||||||
|
if (
|
||||||
|
resp.status_code in _RETRYABLE_STATUS_CODES
|
||||||
|
and attempt < _MAX_GRAPH_RETRIES
|
||||||
|
):
|
||||||
|
delay = self._retry_delay_seconds(resp, attempt)
|
||||||
|
logger.warning(
|
||||||
|
"Microsoft Graph request throttled/unavailable; retrying in %.1fs",
|
||||||
|
delay,
|
||||||
|
)
|
||||||
|
self._sleep(delay)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if resp is None:
|
||||||
|
raise MicrosoftGraphError("Microsoft Graph request failed before receiving a response.")
|
||||||
if resp.status_code < 200 or resp.status_code >= 300:
|
if resp.status_code < 200 or resp.status_code >= 300:
|
||||||
raise MicrosoftGraphError(self._format_error(resp))
|
raise MicrosoftGraphError(self._format_error(resp))
|
||||||
return resp.json() if resp.content else {}
|
return resp.json() if resp.content else {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _retry_delay_seconds(resp: httpx.Response, attempt: int) -> float:
|
||||||
|
retry_after = resp.headers.get("Retry-After")
|
||||||
|
if retry_after:
|
||||||
|
try:
|
||||||
|
return min(float(retry_after), _MAX_RETRY_DELAY_SECONDS)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return min(float(2**attempt), _MAX_RETRY_DELAY_SECONDS)
|
||||||
|
|
||||||
def _refresh_access_token(self) -> None:
|
def _refresh_access_token(self) -> None:
|
||||||
data = {
|
data = {
|
||||||
"client_id": self.client_id,
|
"client_id": self.client_id,
|
||||||
@@ -392,13 +429,15 @@ class MicrosoftGraphClient:
|
|||||||
or lower.endswith(".gzip")
|
or lower.endswith(".gzip")
|
||||||
)
|
)
|
||||||
|
|
||||||
def _list_dmarc_messages(self) -> List[Dict[str, Any]]:
|
def _list_dmarc_messages(self, days: int) -> List[Dict[str, Any]]:
|
||||||
messages: List[Dict[str, Any]] = []
|
messages: List[Dict[str, Any]] = []
|
||||||
url = self._messages_path()
|
url = self._messages_path()
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||||
params: Optional[Dict[str, Any]] = {
|
params: Optional[Dict[str, Any]] = {
|
||||||
"$top": _PAGE_SIZE,
|
"$top": _PAGE_SIZE,
|
||||||
"$select": "id,subject,from,hasAttachments,receivedDateTime",
|
"$select": "id,subject,from,hasAttachments,receivedDateTime",
|
||||||
"$orderby": "receivedDateTime desc",
|
"$orderby": "receivedDateTime desc",
|
||||||
|
"$filter": f"receivedDateTime ge {cutoff.strftime('%Y-%m-%dT%H:%M:%SZ')}",
|
||||||
}
|
}
|
||||||
|
|
||||||
while url:
|
while url:
|
||||||
|
|||||||
@@ -1455,6 +1455,7 @@ class TestMicrosoft365GraphMailSource:
|
|||||||
"new_domains": ["example.com"],
|
"new_domains": ["example.com"],
|
||||||
"errors": [],
|
"errors": [],
|
||||||
"new_ingested_ids": ["id1", "id2", "id3"],
|
"new_ingested_ids": ["id1", "id2", "id3"],
|
||||||
|
"search_window_days": 30,
|
||||||
}
|
}
|
||||||
mock_client.get_refreshed_tokens.return_value = None
|
mock_client.get_refreshed_tokens.return_value = None
|
||||||
|
|
||||||
@@ -1472,7 +1473,7 @@ class TestMicrosoft365GraphMailSource:
|
|||||||
return_value='["id1", "id2", "id3"]',
|
return_value='["id1", "id2", "id3"]',
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/fetch")
|
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/fetch?days=30")
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -1483,6 +1484,8 @@ class TestMicrosoft365GraphMailSource:
|
|||||||
assert data["new_domains"] == ["example.com"]
|
assert data["new_domains"] == ["example.com"]
|
||||||
assert data["target_mailbox"] is None
|
assert data["target_mailbox"] is None
|
||||||
assert data["target_folder"] is None
|
assert data["target_folder"] is None
|
||||||
|
assert data["search_window_days"] == 30
|
||||||
|
mock_client.fetch_reports.assert_called_once_with(days=30)
|
||||||
|
|
||||||
def test_m365_specific_fetch_persists_refreshed_tokens(
|
def test_m365_specific_fetch_persists_refreshed_tokens(
|
||||||
self, authed_client: TestClient, db_session: Session
|
self, authed_client: TestClient, db_session: Session
|
||||||
@@ -2667,12 +2670,13 @@ class TestTriggerPollM365Source:
|
|||||||
patch("app.main.MicrosoftGraphClient.load_ingested_ids", return_value=[]),
|
patch("app.main.MicrosoftGraphClient.load_ingested_ids", return_value=[]),
|
||||||
patch("app.main.MicrosoftGraphClient.dump_ingested_ids", return_value='["id1"]'),
|
patch("app.main.MicrosoftGraphClient.dump_ingested_ids", return_value='["id1"]'),
|
||||||
):
|
):
|
||||||
result = _trigger_poll_m365_source(src, mock_db)
|
result = _trigger_poll_m365_source(src, mock_db, days=30)
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["source_id"] == 8
|
assert result["source_id"] == 8
|
||||||
assert result["processed"] == 2
|
assert result["processed"] == 2
|
||||||
assert src.m365_ingested_ids == '["id1"]'
|
assert src.m365_ingested_ids == '["id1"]'
|
||||||
|
mock_gc.fetch_reports.assert_called_once_with(days=30)
|
||||||
mock_db.commit.assert_called_once()
|
mock_db.commit.assert_called_once()
|
||||||
|
|
||||||
def test_persists_refreshed_tokens(self):
|
def test_persists_refreshed_tokens(self):
|
||||||
@@ -2776,10 +2780,10 @@ class TestPollSourceForTrigger:
|
|||||||
|
|
||||||
expected = {"source_id": 8, "name": "M365", "success": True}
|
expected = {"source_id": 8, "name": "M365", "success": True}
|
||||||
with patch("app.main._trigger_poll_m365_source", return_value=expected) as mock_fn:
|
with patch("app.main._trigger_poll_m365_source", return_value=expected) as mock_fn:
|
||||||
result = _poll_source_for_trigger(src, MagicMock())
|
result = _poll_source_for_trigger(src, MagicMock(), days=30)
|
||||||
|
|
||||||
assert result is expected
|
assert result is expected
|
||||||
mock_fn.assert_called_once()
|
assert mock_fn.call_args.kwargs["days"] == 30
|
||||||
|
|
||||||
def test_m365_exception_returns_failure_dict(self):
|
def test_m365_exception_returns_failure_dict(self):
|
||||||
from app.main import _poll_source_for_trigger
|
from app.main import _poll_source_for_trigger
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ def test_poll_single_m365_source_persists_import_state():
|
|||||||
assert db_source.m365_ingested_ids == '["message-1"]'
|
assert db_source.m365_ingested_ids == '["message-1"]'
|
||||||
assert db_source.m365_access_token == "new-tok"
|
assert db_source.m365_access_token == "new-tok"
|
||||||
assert db_source.m365_refresh_token == "new-ref"
|
assert db_source.m365_refresh_token == "new-ref"
|
||||||
|
mock_client.fetch_reports.assert_called_once_with(days=7)
|
||||||
db.commit.assert_called_once()
|
db.commit.assert_called_once()
|
||||||
db.close.assert_called_once()
|
db.close.assert_called_once()
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ class TestMicrosoftGraphFetchReports:
|
|||||||
def test_fetch_reports_uses_selected_folder_id_and_records_context(self, monkeypatch):
|
def test_fetch_reports_uses_selected_folder_id_and_records_context(self, monkeypatch):
|
||||||
def fake_request(method, url, headers=None, params=None, timeout=None):
|
def fake_request(method, url, headers=None, params=None, timeout=None):
|
||||||
if url.endswith("/users/shared%40example.com/mailFolders/folder-id/messages"):
|
if url.endswith("/users/shared%40example.com/mailFolders/folder-id/messages"):
|
||||||
|
assert "$filter" in params
|
||||||
|
assert params["$filter"].startswith("receivedDateTime ge ")
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
json={
|
json={
|
||||||
@@ -226,6 +228,55 @@ class TestMicrosoftGraphFetchReports:
|
|||||||
assert result["processed"] == 1
|
assert result["processed"] == 1
|
||||||
assert result["target_mailbox"] == "shared@example.com"
|
assert result["target_mailbox"] == "shared@example.com"
|
||||||
assert result["target_folder"] == "DMARC Reports"
|
assert result["target_folder"] == "DMARC Reports"
|
||||||
|
assert result["search_window_days"] == 7
|
||||||
|
|
||||||
|
def test_fetch_reports_applies_requested_search_window(self, monkeypatch):
|
||||||
|
def fake_request(method, url, headers=None, params=None, timeout=None):
|
||||||
|
assert url.endswith("/me/mailFolders/inbox/messages")
|
||||||
|
assert params["$top"] == 100
|
||||||
|
assert params["$select"] == "id,subject,from,hasAttachments,receivedDateTime"
|
||||||
|
assert params["$orderby"] == "receivedDateTime desc"
|
||||||
|
assert params["$filter"].startswith("receivedDateTime ge ")
|
||||||
|
return httpx.Response(200, json={"value": []})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.microsoft_graph_client.httpx.request", fake_request)
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
result = client.fetch_reports(days=30)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["search_window_days"] == 30
|
||||||
|
assert result["processed"] == 0
|
||||||
|
|
||||||
|
def test_request_retries_graph_throttling(self, monkeypatch):
|
||||||
|
responses = [
|
||||||
|
httpx.Response(
|
||||||
|
429,
|
||||||
|
headers={"Retry-After": "0"},
|
||||||
|
json={"error": {"code": "TooManyRequests", "message": "slow down"}},
|
||||||
|
),
|
||||||
|
httpx.Response(200, json={"value": []}),
|
||||||
|
]
|
||||||
|
sleeps = []
|
||||||
|
|
||||||
|
def fake_request(method, url, headers=None, params=None, timeout=None):
|
||||||
|
return responses.pop(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.microsoft_graph_client.httpx.request", fake_request)
|
||||||
|
client = MicrosoftGraphClient(
|
||||||
|
tenant_id="organizations",
|
||||||
|
client_id="client-id",
|
||||||
|
client_secret="client-secret",
|
||||||
|
access_token="access-token",
|
||||||
|
refresh_token="refresh-token",
|
||||||
|
sleep=sleeps.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.fetch_reports()
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert sleeps == [0.0]
|
||||||
|
assert responses == []
|
||||||
|
|
||||||
def test_fetch_reports_imports_zip_attachment(self, monkeypatch, db_session):
|
def test_fetch_reports_imports_zip_attachment(self, monkeypatch, db_session):
|
||||||
attachment_bytes = _zip_xml()
|
attachment_bytes = _zip_xml()
|
||||||
|
|||||||
+1
-1
@@ -206,7 +206,7 @@ Planned:
|
|||||||
- Microsoft 365 mail source using OAuth (Graph) with least-privilege scopes. Delivered for delegated `User.Read`, `Mail.Read`, and `offline_access` with encrypted token storage, manual import, scheduled polling, UI setup, and operator docs.
|
- Microsoft 365 mail source using OAuth (Graph) with least-privilege scopes. Delivered for delegated `User.Read`, `Mail.Read`, and `offline_access` with encrypted token storage, manual import, scheduled polling, UI setup, and operator docs.
|
||||||
- Shared mailbox and folder selection support for DMARC report collection. Delivered with shared mailbox targeting, Microsoft Graph folder listing, folder-id based imports, UI selection, and mailbox/folder context in import history.
|
- Shared mailbox and folder selection support for DMARC report collection. Delivered with shared mailbox targeting, Microsoft Graph folder listing, folder-id based imports, UI selection, and mailbox/folder context in import history.
|
||||||
- Import-history parity with existing sources (auditable attachment outcomes, duplicates, parse failures). Delivered for Microsoft 365 imports.
|
- Import-history parity with existing sources (auditable attachment outcomes, duplicates, parse failures). Delivered for Microsoft 365 imports.
|
||||||
- Backfill support with safe throttling and progressive search windows.
|
- Backfill support with safe throttling and progressive search windows. Delivered with days-based Graph `receivedDateTime` filters, duplicate-safe reruns, and retry/backoff for throttled or temporarily unavailable Graph requests.
|
||||||
- Secret handling mirrors existing guidance (no raw secrets in logs; 1Password-friendly).
|
- Secret handling mirrors existing guidance (no raw secrets in logs; 1Password-friendly).
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ DMARQ uses delegated Microsoft Graph access. That means the authorised Microsoft
|
|||||||
|
|
||||||
## Import Behavior
|
## Import Behavior
|
||||||
|
|
||||||
DMARQ reads recent messages in the configured folder, filters for messages that look like DMARC reports, downloads Graph `fileAttachment` items, and sends `.xml`, `.zip`, `.gz`, and `.gzip` attachments through the same parser and persistence path used by upload, IMAP, and Gmail imports.
|
DMARQ reads messages in the configured search window, filters for messages that look like DMARC reports, downloads Graph `fileAttachment` items, and sends `.xml`, `.zip`, `.gz`, and `.gzip` attachments through the same parser and persistence path used by upload, IMAP, and Gmail imports.
|
||||||
|
|
||||||
Imported Graph message IDs are stored on the mail source so scheduled polling does not reprocess the same message. Import history records processed messages, imported reports, duplicates, parse failures, attachment-level details, and the mailbox/folder target used for the attempt.
|
Imported Graph message IDs are stored on the mail source so scheduled polling does not reprocess the same message. Import history records processed messages, imported reports, duplicates, parse failures, attachment-level details, and the mailbox/folder target used for the attempt.
|
||||||
|
|
||||||
|
Manual imports and backfills use the **days** value from the Mail Sources UI or trigger-poll endpoint. DMARQ passes that window to Microsoft Graph as a `receivedDateTime` filter, so large mailboxes can be searched without scanning unrelated older mail. If Graph returns a throttling or temporary service response, DMARQ retries with `Retry-After` or exponential backoff before surfacing the sanitized error in import history.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **Not authorised**: reconnect the source from Mail Sources.
|
- **Not authorised**: reconnect the source from Mail Sources.
|
||||||
|
|||||||
Reference in New Issue
Block a user