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
+64 -3
View File
@@ -19,13 +19,18 @@ from pathlib import Path
from shutil import rmtree
from tempfile import NamedTemporaryFile, mkdtemp
from typing import BinaryIO, cast
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from lxml import etree # type: ignore[import-untyped]
import parsedmarc
from parsedmarc.mail import MaildirConnection
from parsedmarc.types import AggregateReport, FailureReport, SMTPTLSReport
from parsedmarc.mail import MaildirConnection, MSGraphConnection
from parsedmarc.types import (
AggregateReport,
FailureReport,
ParsingResults,
SMTPTLSReport,
)
# Detect if running in GitHub Actions to skip DNS lookups
OFFLINE_MODE = os.environ.get("GITHUB_ACTIONS", "false").lower() == "true"
@@ -2825,6 +2830,62 @@ class TestEmailResultsErrorBranches(unittest.TestCase):
)
class TestEmailResultsViaMsGraph(unittest.TestCase):
"""email_results_via_msgraph() shares its
subject/message/attachment-building logic with email_results() via the
extracted _build_report_email_content() helper, so both transports stay
in lockstep instead of drifting into two different sets of defaults."""
@staticmethod
def _results() -> ParsingResults:
return {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
def testEmailResultsViaMsGraphBuildsSameContentAsEmailResults(self):
connection = MagicMock(spec=MSGraphConnection, mailbox_name="mb@example.com")
results = self._results()
parsedmarc.email_results_via_msgraph(results, connection, ["admin@example.com"])
connection.send_message.assert_called_once()
graph_kwargs = connection.send_message.call_args.kwargs
with patch("parsedmarc.send_email") as mock_send_email:
parsedmarc.email_results(
results,
host="smtp.example.com",
mail_from="from@example.com",
mail_to=["admin@example.com"],
)
mock_send_email.assert_called_once()
smtp_kwargs = mock_send_email.call_args.kwargs
self.assertEqual(graph_kwargs["subject"], smtp_kwargs["subject"])
self.assertEqual(graph_kwargs["plain_message"], smtp_kwargs["plain_message"])
self.assertEqual(
graph_kwargs["attachments"][0][0],
smtp_kwargs["attachments"][0][0],
)
self.assertEqual(graph_kwargs["message_to"], ["admin@example.com"])
self.assertEqual(graph_kwargs["message_from"], "mb@example.com")
def testEmailResultsViaMsGraphAppendsZipExtension(self):
connection = MagicMock(spec=MSGraphConnection, mailbox_name="mb@example.com")
parsedmarc.email_results_via_msgraph(
self._results(),
connection,
["admin@example.com"],
attachment_filename="report",
)
graph_kwargs = connection.send_message.call_args.kwargs
self.assertEqual(graph_kwargs["attachments"][0][0], "report.zip")
class TestAppendJson(unittest.TestCase):
"""append_json writes new files cleanly and merges into existing
JSON arrays without breaking valid JSON."""