Merge pull request #183 from christianlouis/codex/m12-m365-safe-backfills

feat: add M365 safe backfill windows
This commit is contained in:
Christian Krakau-Louis
2026-05-23 15:46:37 +02:00
committed by GitHub
8 changed files with 122 additions and 23 deletions
@@ -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
View File
@@ -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).
+46 -7
View File
@@ -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:
+8 -4
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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:
+3 -1
View File
@@ -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.