Refresh Microsoft Graph docs: national clouds, examples, troubleshooting (#826)

* Send report summary via Microsoft Graph; make Graph failures observable

Two related fixes shipped together:

Send via Graph: the periodic DMARC summary email can now be sent
through the already-authenticated Microsoft Graph mailbox connection
(MSGraphConnection.send_message(), /users/{mailbox}/sendMail) instead
of only SMTP. Triggered when [msgraph] is configured and [smtp] has a
`to` value but no `host` -- SMTP is always preferred when `host` is
set, with no automatic fallback to Graph on SMTP failure. Reuses the
same connection used for reading; no new send-only config mode.
email_results()'s SMTP behavior is unchanged; a new
email_results_via_msgraph() shares its content-building logic via a
new _build_report_email_content() helper. Graph's sendMail always
sends as the authenticated mailbox, so [smtp] from is ignored on this
path -- documented, along with the required Mail.Send permissions and
a caveat that delegated auth flows (UsernamePassword/DeviceCode) don't
currently request that scope, so app-only auth is the supported path
for sending. Tracks #472.

Observable Graph failures: MSGraphConnection construction, mailbox
fetch, message send, and --watch failures now catch
ClientAuthenticationError/APIError/httpx.HTTPError specifically and
log one clear ERROR line naming the mailbox, tenant, auth method, and
the Graph request-id/client-request-id when available, instead of a
bare "MS Graph Error"/"Mailbox Error" with no context. Full traceback
still preserved at --debug. --watch previously had no Graph-specific
error handling at all -- a Graph error there crashed with a raw
uncaught traceback; it now exits the same way as the other three
sites. No new config options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Refresh Microsoft Graph docs: national clouds, examples, troubleshooting

The [msgraph] docs were accurate but missed guidance the community has
been asking for:

- graph_url now lists the actual national/sovereign-cloud endpoint
  values (GCC High, DoD, China/21Vianet), with an explicit warning
  that setting it alone is not sufficient -- the Entra ID auth
  endpoint isn't independently configurable in parsedmarc or
  mailsuite, so it always hits the global login.microsoftonline.com.
- A minimal working [msgraph] example for every auth method
  (UsernamePassword, DeviceCode, ClientSecret, Certificate,
  ClientAssertion) -- previously only Certificate had one, entangled
  with the SMTP-sending example.
- A reading-permission matrix alongside the existing sending one, so
  every auth method x own/shared-mailbox combination is explicit in
  one place for both directions.
- An accurate note on the parsedmarc-named token cache: it's a
  deliberate backward-compatibility choice from the 9.11.0 mailsuite
  extraction (mailsuite's own default cache name differs), not a
  migration users need to act on.
- A troubleshooting table for four error scenarios, verified against
  source rather than assumed: admin consent and folder-resolution
  failures are still live and documented with real fixes; the
  event-loop and ISO-timestamp errors are historical, already fixed
  below this project's dependency/version floor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kili
2026-07-14 13:34:56 -04:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 40509f801b
commit 31c928d6fc
6 changed files with 842 additions and 42 deletions
+381
View File
@@ -9,12 +9,19 @@ import signal
import sys
import tempfile
import unittest
import zipfile
from configparser import ConfigParser
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import httpx
from azure.core.exceptions import ClientAuthenticationError
from msgraph.generated.models.o_data_errors.inner_error import InnerError
from msgraph.generated.models.o_data_errors.main_error import MainError
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
import parsedmarc
import parsedmarc.cli
import parsedmarc.elastic
@@ -1828,6 +1835,380 @@ client_assertion = s3cret-signed-jwt-assertion
self.assertEqual(logging.getLogger(name).level, logging.WARNING, name)
class TestMSGraphEmailResults(unittest.TestCase):
"""#472: the periodic summary email is sent via the same
already-authenticated Microsoft Graph mailbox connection when
[smtp] host is not configured but [msgraph] is, so M365 tenants that
block legacy SMTP AUTH can still receive the summary from the
mailbox they already read reports from. SMTP is preferred when
[smtp] host is set."""
CERT_CONFIG = """[general]
silent = true
[msgraph]
auth_method = Certificate
client_id = client-id-1234
tenant_id = tenant-id-5678
mailbox = shared@example.com
certificate_path = /tmp/msgraph-cert.pem
certificate_password = s3cret-cert-pass
"""
def setUp(self):
# _configure_dependency_logging mutates process-global loggers;
# snapshot and restore their levels and handlers so these tests
# don't leak state into the rest of the suite.
saved = {}
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
dep = logging.getLogger(name)
saved[name] = (dep.level, list(dep.handlers), dep.propagate)
def restore():
for name, (level, handlers, propagate) in saved.items():
dep = logging.getLogger(name)
dep.setLevel(level)
dep.handlers = handlers
dep.propagate = propagate
self.addCleanup(restore)
def _write_config(self, config_text):
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
cfg.write(config_text)
cfg_path = cfg.name
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
return cfg_path
def _run_main(self, cfg_path, *cli_args):
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, *cli_args]):
parsedmarc.cli._main()
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliSendsSummaryEmailViaMsGraphWhenNoSmtpHost(
self, mock_graph_connection, mock_get_mailbox_reports
):
"""[smtp] with only to/subject (no host) plus [msgraph] sends the
summary via Microsoft Graph's sendMail."""
mock_get_mailbox_reports.return_value = {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
config_text = (
self.CERT_CONFIG
+ """
[smtp]
to = admin@example.com
subject = DMARC Summary
"""
)
cfg_path = self._write_config(config_text)
self._run_main(cfg_path)
send_message = mock_graph_connection.return_value.send_message
send_message.assert_called_once()
call_kwargs = send_message.call_args.kwargs
self.assertEqual(call_kwargs["message_to"], ["admin@example.com"])
self.assertEqual(call_kwargs["subject"], "DMARC Summary")
filename, payload = call_kwargs["attachments"][0]
self.assertRegex(filename, r"^DMARC-\d{4}-\d{2}-\d{2}\.zip$")
with zipfile.ZipFile(io.BytesIO(payload)) as zf:
self.assertIsNone(zf.testzip())
@patch("parsedmarc.cli.email_results")
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliPrefersSmtpWhenBothSmtpAndMsGraphConfigured(
self, mock_graph_connection, mock_get_mailbox_reports, mock_email_results
):
"""SMTP is preferred over Microsoft Graph when [smtp] host is
set, even with [msgraph] also configured — no fallback, no
dual-send."""
mock_get_mailbox_reports.return_value = {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
config_text = (
self.CERT_CONFIG
+ """
[smtp]
host = smtp.example.com
user = smtp-user
password = smtp-password
from = dmarc@example.com
to = admin@example.com
"""
)
cfg_path = self._write_config(config_text)
self._run_main(cfg_path)
mock_email_results.assert_called_once()
call_args = mock_email_results.call_args
self.assertEqual(call_args.args[1], "smtp.example.com")
self.assertEqual(call_args.args[2], "dmarc@example.com")
self.assertEqual(call_args.args[3], ["admin@example.com"])
self.assertEqual(call_args.kwargs["username"], "smtp-user")
self.assertEqual(call_args.kwargs["password"], "smtp-password")
mock_graph_connection.return_value.send_message.assert_not_called()
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliSkipsGraphSendWithoutSmtpSection(
self, mock_graph_connection, mock_get_mailbox_reports
):
"""A [msgraph]-only, read-only config (no [smtp] section at all)
sends nothing — unchanged behavior for existing reading-only
users."""
mock_get_mailbox_reports.return_value = {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
cfg_path = self._write_config(self.CERT_CONFIG)
self._run_main(cfg_path)
mock_graph_connection.return_value.send_message.assert_not_called()
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
@patch("parsedmarc.cli.logger")
def testCliSmtpWithoutHostRequiresMsGraph(
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
):
"""[smtp] with to but no host, and no [msgraph] configured at
all, still fails config parsing exactly as it did before this
feature — host is only optional when a Graph connection can
send instead."""
config_text = """[general]
silent = true
[smtp]
to = admin@example.com
"""
cfg_path = self._write_config(config_text)
with self.assertRaises(SystemExit) as system_exit:
self._run_main(cfg_path)
self.assertEqual(system_exit.exception.code, -1)
mock_logger.critical.assert_called_once_with(
"host setting missing from the smtp config section"
)
mock_graph_connection.assert_not_called()
mock_get_mailbox_reports.assert_not_called()
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliLogsMsGraphSendFailure(
self, mock_graph_connection, mock_get_mailbox_reports
):
"""A Graph sendMail failure gets the same single-ERROR-line
treatment as connection/fetch failures."""
mock_get_mailbox_reports.return_value = {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
mock_graph_connection.return_value.send_message.side_effect = ODataError(
response_status_code=403,
error=MainError(
message="Access is denied",
inner_error=InnerError(request_id="rid-1", client_request_id="crid-1"),
),
)
config_text = (
self.CERT_CONFIG
+ """
[smtp]
to = admin@example.com
subject = DMARC Summary
"""
)
cfg_path = self._write_config(config_text)
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
with self.assertRaises(SystemExit) as system_exit:
self._run_main(cfg_path)
self.assertEqual(system_exit.exception.code, 1)
output = "\n".join(cm.output)
self.assertIn("Microsoft Graph message send failed", output)
self.assertIn("mailbox=", output)
self.assertIn("tenant_id=", output)
self.assertIn("auth_method=Certificate", output)
self.assertIn("status=403", output)
self.assertIn("request-id=rid-1", output)
self.assertIn("client-request-id=crid-1", output)
class TestMSGraphFailureLogging(unittest.TestCase):
"""Microsoft Graph connection/fetch/watch failures log a single
clear ERROR line identifying the mailbox/tenant/auth method and
the Graph request-id/client-request-id when available, instead of
a bare logger.exception() that hides the actual error."""
CERT_CONFIG = """[general]
silent = true
[msgraph]
auth_method = Certificate
client_id = client-id-1234
tenant_id = tenant-id-5678
mailbox = shared@example.com
certificate_path = /tmp/msgraph-cert.pem
certificate_password = s3cret-cert-pass
"""
def setUp(self):
saved = {}
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
dep = logging.getLogger(name)
saved[name] = (dep.level, list(dep.handlers), dep.propagate)
def restore():
for name, (level, handlers, propagate) in saved.items():
dep = logging.getLogger(name)
dep.setLevel(level)
dep.handlers = handlers
dep.propagate = propagate
self.addCleanup(restore)
def _write_config(self, config_text):
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
cfg.write(config_text)
cfg_path = cfg.name
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
return cfg_path
def _run_main(self, cfg_path, *cli_args):
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, *cli_args]):
parsedmarc.cli._main()
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliLogsMsGraphConnectionAuthFailureContext(
self, mock_graph_connection, mock_get_mailbox_reports
):
"""An auth failure during connection construction logs the
redacted context plus the actionable AADSTS code verbatim."""
mock_graph_connection.side_effect = ClientAuthenticationError(
"AADSTS7000215: Invalid client secret"
)
cfg_path = self._write_config(self.CERT_CONFIG)
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
with self.assertRaises(SystemExit) as system_exit:
self._run_main(cfg_path)
self.assertEqual(system_exit.exception.code, 1)
output = "\n".join(cm.output)
self.assertIn("Microsoft Graph connection failed", output)
self.assertIn("mailbox=shared@example.com", output)
self.assertIn("tenant_id=tenant-id-5678", output)
self.assertIn("auth_method=Certificate", output)
self.assertIn("AADSTS7000215", output)
mock_get_mailbox_reports.assert_not_called()
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliLogsMsGraphMailboxFetchFailureWithRequestId(
self, mock_graph_connection, mock_get_mailbox_reports
):
"""A mailbox-fetch failure (the most common real-world auth
failure point, since app-only auth defers token acquisition to
first use) surfaces both OData inner-error request ids."""
mock_get_mailbox_reports.side_effect = ODataError(
response_status_code=503,
error=MainError(
message="Service unavailable",
inner_error=InnerError(request_id="rid-2", client_request_id="crid-2"),
),
)
cfg_path = self._write_config(self.CERT_CONFIG)
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
with self.assertRaises(SystemExit) as system_exit:
self._run_main(cfg_path)
self.assertEqual(system_exit.exception.code, 1)
output = "\n".join(cm.output)
self.assertIn("Microsoft Graph mailbox fetch failed", output)
self.assertIn("request-id=rid-2", output)
self.assertIn("client-request-id=crid-2", output)
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliMsGraphErrorFallsBackToResponseHeaderRequestId(
self, mock_graph_connection, mock_get_mailbox_reports
):
"""When the response body didn't deserialize into a proper OData
inner error, the request-id still surfaces from the raw
response headers."""
mock_get_mailbox_reports.side_effect = ODataError(
response_status_code=503,
response_headers={"request-id": "hdr-rid"},
error=None,
)
cfg_path = self._write_config(self.CERT_CONFIG)
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
with self.assertRaises(SystemExit):
self._run_main(cfg_path)
output = "\n".join(cm.output)
self.assertIn("request-id=hdr-rid", output)
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliMsGraphErrorOmitsRequestIdWhenAbsent(
self, mock_graph_connection, mock_get_mailbox_reports
):
"""When neither an inner error nor a response header carries a
request id, the ERROR line simply omits the suffix rather than
printing an empty/misleading id."""
mock_get_mailbox_reports.side_effect = ODataError(response_status_code=500)
cfg_path = self._write_config(self.CERT_CONFIG)
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
with self.assertRaises(SystemExit) as system_exit:
self._run_main(cfg_path)
self.assertEqual(system_exit.exception.code, 1)
output = "\n".join(cm.output)
self.assertNotIn("request-id=", output)
@patch("parsedmarc.cli.watch_inbox", side_effect=httpx.ConnectError("dns failure"))
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testWatchModeLogsMsGraphErrorAndExits(
self, mock_graph_connection, mock_get_mailbox_reports, mock_watch_inbox
):
"""Before this fix, --watch had no catch-all at all for Graph
errors, so a token/cert expiry mid-watch crashed with a raw
uncaught traceback. It now gets the same single ERROR line."""
mock_get_mailbox_reports.return_value = {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
config_text = self.CERT_CONFIG + "\n[mailbox]\nwatch = true\n"
cfg_path = self._write_config(config_text)
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
with self.assertRaises(SystemExit) as system_exit:
self._run_main(cfg_path)
self.assertEqual(system_exit.exception.code, 1)
output = "\n".join(cm.output)
self.assertIn("Microsoft Graph mailbox watch failed", output)
self.assertIn("ConnectError", output)
class TestSighupReload(unittest.TestCase):
"""Tests for SIGHUP-driven configuration reload in watch mode."""