feat: add Microsoft 365 Graph mail source

This commit is contained in:
Christian Krakau-Louis
2026-05-23 14:49:54 +02:00
parent 08af80fa0e
commit 5caefb06db
12 changed files with 2379 additions and 21 deletions
+136 -2
View File
@@ -30,6 +30,7 @@ from app.models.mail_source import MailSource # noqa: F401 ensure table is
from app.services.gmail_client import GmailClient
from app.services.imap_client import IMAPClient
from app.services.import_history import record_import_attempt
from app.services.microsoft_graph_client import MicrosoftGraphClient
from app.services.report_persistence import hydrate_report_store_from_db
from app.services.report_store import ReportStore
from app.services.runtime_status import (
@@ -162,7 +163,74 @@ def _poll_single_gmail_source(source: MailSource) -> None:
)
def _poll_all_enabled_sources() -> list[MailSource]:
def _poll_single_m365_source(source: MailSource) -> None:
"""Fetch DMARC reports for a single M365_GRAPH mail source."""
global last_check_time # pylint: disable=global-statement
if not source.m365_access_token:
logger.info(
"Microsoft 365 polling (source id=%d): skipped OAuth2 not yet authorised",
source.id,
)
return
db = SessionLocal()
try:
src = db.query(MailSource).get(source.id)
poll_source = src or source
already = MicrosoftGraphClient.load_ingested_ids(poll_source.m365_ingested_ids)
client = MicrosoftGraphClient(
tenant_id=poll_source.m365_tenant_id or "common",
client_id=poll_source.m365_client_id or "",
client_secret=poll_source.m365_client_secret or "",
access_token=poll_source.m365_access_token,
refresh_token=poll_source.m365_refresh_token or "",
mailbox=poll_source.m365_mailbox,
folder=poll_source.folder or "INBOX",
already_ingested_ids=already,
db=db,
)
started_at = datetime.utcnow()
results = client.fetch_reports()
if src:
if results.get("new_ingested_ids"):
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
src.m365_ingested_ids = MicrosoftGraphClient.dump_ingested_ids(all_ids)
refreshed = client.get_refreshed_tokens()
if refreshed:
src.m365_access_token = refreshed["access_token"]
if "refresh_token" in refreshed:
src.m365_refresh_token = refreshed["refresh_token"]
src.last_checked = datetime.utcnow()
record_import_attempt(db, src, results, started_at=started_at, trigger="scheduled")
db.commit()
finally:
db.close()
last_check_time = datetime.now()
if results["success"]:
logger.info(
"Microsoft 365 polling (source id=%d): %s emails processed, "
"%s aggregate reports found",
source.id,
results["processed"],
results["reports_found"],
)
if results["new_domains"]:
logger.info("New domains found: %s", ", ".join(results["new_domains"]))
else:
logger.error(
"Microsoft 365 polling (source id=%d) failed: %s",
source.id,
results.get("error", "Unknown error"),
)
def _poll_all_enabled_sources() -> list[MailSource]: # noqa: C901
"""Iterate over all enabled mail sources and poll each one."""
db = SessionLocal()
try:
@@ -182,6 +250,11 @@ def _poll_all_enabled_sources() -> list[MailSource]:
_poll_single_gmail_source(source)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error polling Gmail source id=%d: %s", source.id, str(e))
elif source.method == "M365_GRAPH":
try:
_poll_single_m365_source(source)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error polling Microsoft 365 source id=%d: %s", source.id, str(e))
elif source.method == "IMAP":
try:
_poll_single_imap_source(source)
@@ -682,7 +755,50 @@ def _trigger_poll_gmail_source(source: MailSource, db) -> dict:
}
def _poll_source_for_trigger(source: MailSource, db, days: int = 7) -> dict:
def _trigger_poll_m365_source(source: MailSource, db) -> dict:
"""Poll a single M365_GRAPH source and return a result dict for the API response."""
global last_check_time # pylint: disable=global-statement
already = MicrosoftGraphClient.load_ingested_ids(source.m365_ingested_ids)
graph_client = MicrosoftGraphClient(
tenant_id=source.m365_tenant_id or "common",
client_id=source.m365_client_id or "",
client_secret=source.m365_client_secret or "",
access_token=source.m365_access_token,
refresh_token=source.m365_refresh_token or "",
mailbox=source.m365_mailbox,
folder=source.folder or "INBOX",
already_ingested_ids=already,
db=db,
)
started_at = datetime.utcnow()
results = graph_client.fetch_reports()
last_check_time = datetime.now()
if results.get("new_ingested_ids"):
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
source.m365_ingested_ids = MicrosoftGraphClient.dump_ingested_ids(all_ids)
refreshed = graph_client.get_refreshed_tokens()
if refreshed:
source.m365_access_token = refreshed["access_token"]
if "refresh_token" in refreshed:
source.m365_refresh_token = refreshed["refresh_token"]
source.last_checked = datetime.utcnow()
record_import_attempt(db, source, results, started_at=started_at, trigger="manual")
db.commit()
return {
"source_id": source.id,
"name": source.name,
"success": results["success"],
"processed": results.get("processed", 0),
"reports_found": results.get("reports_found", 0),
"forensic_reports_found": results.get("forensic_reports_found", 0),
"duplicate_forensic_reports": results.get("duplicate_forensic_reports", 0),
"new_domains": results.get("new_domains", []),
}
def _poll_source_for_trigger(source: MailSource, db, days: int = 7) -> dict: # noqa: C901
"""Dispatch a single mail source for the manual trigger-poll endpoint.
Returns a result/summary dict that is included in the API response.
@@ -705,6 +821,24 @@ def _poll_source_for_trigger(source: MailSource, db, days: int = 7) -> dict:
"success": False,
"error": "Failed to poll. Check server logs for details.",
}
if source.method == "M365_GRAPH":
if not source.m365_access_token:
return {
"source_id": source.id,
"name": source.name,
"skipped": True,
"reason": "Microsoft 365 account not yet authorised",
}
try:
return _trigger_poll_m365_source(source, db)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error polling Microsoft 365 source id=%d: %s", source.id, str(e))
return {
"source_id": source.id,
"name": source.name,
"success": False,
"error": "Failed to poll. Check server logs for details.",
}
if source.method == "IMAP":
try:
return _trigger_poll_imap_source(source, db, days=days)