Files
gh-christianlouis-dmarq/backend/app/services/gmail_client.py
T
2026-05-23 15:57:34 +02:00

544 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Gmail API client for retrieving DMARC reports.
Connects to Gmail via OAuth 2.0, searches for emails that are likely to
contain DMARC aggregate-report attachments, and processes any new ones.
Already-ingested message IDs are tracked so the same email is never
processed twice (no messages are modified or deleted).
"""
import base64
import email
import logging
from typing import Any, Dict, List, Optional
from urllib.parse import urlencode
import httpx
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from app.services.dmarc_parser import DMARCParser
from app.services.forensic_parser import ForensicParser
from app.services.forensic_persistence import forensic_report_exists, save_forensic_report
from app.services.forensic_redaction import get_forensic_redaction_policy
from app.services.mail_connector import (
append_import_detail,
connector_failure_stats,
dump_ingested_ids,
initial_import_stats,
load_ingested_ids,
sanitize_connector_error,
)
from app.services.report_persistence import report_exists, save_parsed_report
from app.services.report_store import ReportStore
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# OAuth2 scopes read-only access to Gmail messages is all we need
# ---------------------------------------------------------------------------
GMAIL_SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
]
# ---------------------------------------------------------------------------
# Gmail search query used to find emails likely containing DMARC reports.
#
# Strategy:
# • Require at least one attachment whose name ends in .zip, .gz, or .xml
# (the three formats used by virtually every DMARC sender).
# • Additionally require *either* a keyword in the subject that DMARC senders
# use, or an envelope-from that belongs to a well-known DMARC reporting
# address. This keeps false-positive rates low while catching reports
# from providers that don't follow naming conventions perfectly.
# ---------------------------------------------------------------------------
DMARC_GMAIL_QUERY = (
"((has:attachment (filename:zip OR filename:gz OR filename:xml)) "
'OR subject:"DMARC failure" OR subject:"failure report" OR subject:forensic OR subject:ruf) '
"(subject:dmarc OR subject:report OR subject:rua OR subject:submitter "
'OR subject:"aggregate report" OR subject:"domain report" '
'OR subject:"report domain" OR from:dmarc OR from:dmarc-noreply '
"OR from:noreply-dmarc-support OR from:reports OR from:postmaster)"
)
# How many message results to fetch per API page
_PAGE_SIZE = 100
RETRYABLE_MESSAGE_FAILURE = -1
class GmailClient:
"""
Client for retrieving DMARC reports from a Gmail account via the Gmail API.
OAuth2 tokens are accepted at construction time and auto-refreshed when
expired. The caller is responsible for persisting any refreshed tokens
returned by :meth:`get_refreshed_tokens`.
"""
def __init__(
self,
client_id: str,
client_secret: str,
access_token: str,
refresh_token: str,
already_ingested_ids: Optional[List[str]] = None,
db: Any = None,
):
self.client_id = client_id
self.client_secret = client_secret
self._initial_access_token = access_token
self.already_ingested_ids: List[str] = list(already_ingested_ids or [])
self.report_store = ReportStore.get_instance()
self.db = db
self.credentials = Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=client_id,
client_secret=client_secret,
scopes=GMAIL_SCOPES,
)
# ------------------------------------------------------------------
# Public helpers
# ------------------------------------------------------------------
def get_refreshed_tokens(self) -> Optional[Dict[str, str]]:
"""
Return updated tokens if the google-auth library has refreshed them.
Call this after :meth:`fetch_reports` and persist any non-None result
so the next run doesn't need an extra refresh round-trip.
"""
current = self.credentials.token
if current and current != self._initial_access_token:
result: Dict[str, str] = {"access_token": current}
if self.credentials.refresh_token:
result["refresh_token"] = self.credentials.refresh_token
return result
return None
# ------------------------------------------------------------------
# OAuth2 helpers (static / class methods used by the endpoint layer)
# ------------------------------------------------------------------
@staticmethod
def build_authorization_url(
client_id: str,
redirect_uri: str,
state: Optional[str] = None,
) -> str:
"""
Construct the Google OAuth2 authorization URL.
Requests offline access so a refresh token is issued, and forces
the consent screen so the refresh token is always returned even if
the user has authorised this app before.
"""
params: Dict[str, str] = {
"client_id": client_id,
"response_type": "code",
"scope": " ".join(GMAIL_SCOPES),
"redirect_uri": redirect_uri,
"access_type": "offline",
"prompt": "consent",
}
if state:
params["state"] = state
return "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params)
@staticmethod
def exchange_code_for_tokens(
client_id: str,
client_secret: str,
code: str,
redirect_uri: str,
) -> Dict[str, Any]:
"""
Synchronously exchange an authorization code for access+refresh tokens.
Returns the raw JSON from Google's token endpoint. The caller
should check for ``access_token`` in the result before using it.
Raises:
ValueError: if Google returns a non-200 response.
"""
resp = httpx.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
raise ValueError(f"Token exchange failed ({resp.status_code}): {resp.text}")
return resp.json()
@staticmethod
def get_gmail_email(access_token: str) -> Optional[str]:
"""
Return the email address associated with an access token.
Uses the OAuth2 userinfo endpoint. Returns None on failure.
"""
try:
resp = httpx.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code == 200:
return resp.json().get("email")
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Failed to fetch Gmail email address: %s", exc)
return None
# ------------------------------------------------------------------
# Core fetching logic
# ------------------------------------------------------------------
def fetch_reports(self) -> Dict[str, Any]:
"""
Search Gmail for DMARC report emails and ingest any new ones.
Emails that have already been ingested (tracked via
``already_ingested_ids``) are silently skipped. No messages are
modified or deleted.
Returns:
A dict with keys ``success``, ``processed``, ``reports_found``,
``new_domains``, ``errors``, and ``new_ingested_ids`` (the IDs
added in this run so the caller can persist them).
"""
stats = initial_import_stats()
try:
service = self._build_service()
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Gmail API: failed to build service: %s", exc)
return connector_failure_stats(stats, "Failed to initialize Gmail API.", error=exc)
try:
message_ids = self._list_dmarc_message_ids(service)
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Gmail API: failed to list messages: %s", exc)
return connector_failure_stats(stats, "Failed to list Gmail messages.", error=exc)
domains_before = set(self.report_store.get_domains())
for msg_id in message_ids:
if msg_id in self.already_ingested_ids:
self._append_detail(
stats,
status="skipped",
reason="already_ingested_message",
message_id=msg_id,
)
continue
stats["processed"] += 1
found = self._process_message(service, msg_id, stats)
if found >= 0:
# Track it even when no report is found so we don't re-examine
# unrelated messages on every poll. Retryable failures return -1.
stats["new_ingested_ids"].append(msg_id)
self.already_ingested_ids.append(msg_id)
domains_after = set(self.report_store.get_domains())
stats["new_domains"] = list(domains_after - domains_before)
return stats
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _build_service(self):
"""Build (and auto-refresh if needed) the Gmail API service object."""
if self.credentials.expired and self.credentials.refresh_token:
try:
self.credentials.refresh(Request())
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Gmail token refresh failed: %s", exc)
raise
return build("gmail", "v1", credentials=self.credentials, cache_discovery=False)
def _list_dmarc_message_ids(self, service) -> List[str]:
"""Return all Gmail message IDs matching the DMARC search query."""
ids: List[str] = []
page_token: Optional[str] = None
while True:
kwargs: Dict[str, Any] = {
"userId": "me",
"q": DMARC_GMAIL_QUERY,
"maxResults": _PAGE_SIZE,
}
if page_token:
kwargs["pageToken"] = page_token
try:
result = service.users().messages().list(**kwargs).execute()
except HttpError as exc:
logger.error("Gmail API list error: %s", exc)
raise
for msg in result.get("messages", []):
ids.append(msg["id"])
page_token = result.get("nextPageToken")
if not page_token:
break
return ids
@staticmethod
def _append_detail(stats: dict, **detail: str) -> None:
"""Append a compact attachment/message outcome to the import stats."""
append_import_detail(stats, **detail)
def _process_message(self, service, msg_id: str, stats: dict) -> int:
"""
Download a Gmail message and process any DMARC-report attachments.
Returns the number of DMARC reports found in this message.
"""
try:
msg_data = (
service.users().messages().get(userId="me", id=msg_id, format="raw").execute()
)
except HttpError as exc:
logger.error("Gmail API: failed to fetch message %s: %s", msg_id, exc)
stats["errors"].append(sanitize_connector_error(f"Failed to fetch message {msg_id}"))
self._append_detail(
stats,
status="error",
reason="message_fetch_failed",
message_id=msg_id,
)
return 0
raw_bytes = base64.urlsafe_b64decode(msg_data.get("raw", ""))
msg = email.message_from_bytes(raw_bytes)
if ForensicParser.is_forensic_report(msg):
return self._process_forensic_message(raw_bytes, stats, message_id=msg_id)
return self._process_attachments(msg, stats, message_id=msg_id)
@staticmethod
def _decode_part_filename(part: email.message.Message) -> str:
"""Return the decoded filename for a MIME part (handles RFC 2047 encoding)."""
from email.header import decode_header
raw_name = part.get_filename() or ""
decoded_parts = []
for fragment, charset in decode_header(raw_name):
if isinstance(fragment, bytes):
decoded_parts.append(fragment.decode(charset or "utf-8", errors="replace"))
else:
decoded_parts.append(fragment)
return "".join(decoded_parts)
@staticmethod
def _is_dmarc_attachment(filename: str) -> bool:
"""Return True if *filename* looks like a DMARC aggregate-report file."""
lower = filename.lower()
return (
lower.endswith(".xml")
or lower.endswith(".zip")
or lower.endswith(".gz")
or lower.endswith(".gzip")
)
def _store_report_if_new(self, report: Dict[str, Any]) -> bool:
"""Store a parsed report unless that domain/report ID is already present."""
domain = report.get("domain", "unknown")
report_id = report.get("report_id", "")
if report_id and (
self.report_store.has_report(domain, report_id)
or (self.db is not None and report_exists(self.db, domain, report_id))
):
logger.info("Skipping duplicate DMARC report %s for %s", report_id, domain)
return False
if self.db is not None:
save_parsed_report(self.db, report)
self.report_store.add_report(report)
return True
def _process_forensic_message(
self,
raw_bytes: bytes,
stats: dict,
message_id: Optional[str] = None,
) -> int:
"""Parse and persist one DMARC forensic report message."""
try:
report = ForensicParser.parse_bytes(
raw_bytes,
message_id_hint=message_id,
redaction_policy=get_forensic_redaction_policy(self.db),
)
report_id = str(report.get("report_id", ""))
domain = str(report.get("reported_domain") or "unknown")
if self.db is None:
self._append_detail(
stats,
status="skipped",
reason="forensic_report_requires_database",
message_id=message_id,
domain=domain,
report_id=report_id,
)
return RETRYABLE_MESSAGE_FAILURE
if forensic_report_exists(self.db, report_id):
stats["duplicate_forensic_reports"] = stats.get("duplicate_forensic_reports", 0) + 1
self._append_detail(
stats,
status="duplicate",
reason="duplicate_forensic_report",
message_id=message_id,
domain=domain,
report_id=report_id,
)
return 0
_row, created = save_forensic_report(self.db, report)
if not created:
stats["duplicate_forensic_reports"] = stats.get("duplicate_forensic_reports", 0) + 1
self._append_detail(
stats,
status="duplicate",
reason="duplicate_forensic_report",
message_id=message_id,
domain=domain,
report_id=report_id,
)
return 0
stats["forensic_reports_found"] = stats.get("forensic_reports_found", 0) + 1
self._append_detail(
stats,
status="imported",
reason="forensic_report",
message_id=message_id,
domain=domain,
report_id=report_id,
)
return 1
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Failed to parse Gmail forensic report %s: %s", message_id, exc)
stats.setdefault("errors", []).append(
sanitize_connector_error(f"Failed to parse forensic report {message_id}: {exc}")
)
self._append_detail(
stats,
status="error",
reason="forensic_parse_failed",
message_id=message_id,
error=str(exc),
)
return RETRYABLE_MESSAGE_FAILURE
def _process_attachments(
self,
msg: email.message.Message,
stats: dict,
message_id: Optional[str] = None,
) -> int:
"""Walk a parsed email message and extract DMARC report attachments."""
reports_found = 0
for part in msg.walk():
filename = self._decode_part_filename(part)
if not filename:
continue
disposition = part.get_content_disposition()
if disposition not in ("attachment", None):
continue
if not self._is_dmarc_attachment(filename):
self._append_detail(
stats,
status="skipped",
reason="unsupported_attachment",
message_id=message_id,
filename=filename,
)
continue
content = part.get_payload(decode=True)
if not content:
self._append_detail(
stats,
status="skipped",
reason="empty_attachment",
message_id=message_id,
filename=filename,
)
continue
try:
report = DMARCParser.parse_file(content, filename)
domain = str(report.get("domain", "unknown"))
report_id = str(report.get("report_id", ""))
if self._store_report_if_new(report):
stats["reports_found"] += 1
reports_found += 1
self._append_detail(
stats,
status="imported",
message_id=message_id,
filename=filename,
domain=domain,
report_id=report_id,
)
else:
stats["duplicate_reports"] = stats.get("duplicate_reports", 0) + 1
self._append_detail(
stats,
status="duplicate",
message_id=message_id,
filename=filename,
domain=domain,
report_id=report_id,
)
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Failed to parse DMARC attachment %s: %s", filename, exc)
stats["errors"].append(
sanitize_connector_error(f"Failed to parse {filename}: {exc}")
)
self._append_detail(
stats,
status="error",
reason="parse_failed",
message_id=message_id,
filename=filename,
error=str(exc),
)
return reports_found
# ------------------------------------------------------------------
# Convenience: load / save ingested IDs from/to the JSON text column
# ------------------------------------------------------------------
@staticmethod
def load_ingested_ids(json_text: Optional[str]) -> List[str]:
"""Deserialise the gmail_ingested_ids text column into a list."""
return load_ingested_ids(json_text)
@staticmethod
def dump_ingested_ids(ids: List[str]) -> str:
"""Serialise the list of ingested IDs back to a JSON string."""
return dump_ingested_ids(ids)