diff --git a/docs/source/example.ini b/docs/source/example.ini index faeb5f37..b9e1a4ba 100644 --- a/docs/source/example.ini +++ b/docs/source/example.ini @@ -29,3 +29,14 @@ token_file = /etc/example/token.json include_spam_trash = True paginate_messages = True scopes = https://www.googleapis.com/auth/gmail.modify + +[msgraph] +auth_method = ClientSecret +client_id = 12345678-90ab-cdef-1234-567890abcdef +client_secret = your-client-secret-here +tenant_id = 12345678-90ab-cdef-1234-567890abcdef +mailbox = dmarc-reports@example.com +# Use standard folder names - they work across all locales +# and avoid "Default folder Root not found" errors +reports_folder = Inbox +archive_folder = Archive diff --git a/docs/source/usage.md b/docs/source/usage.md index 85eec61d..f3b824aa 100644 --- a/docs/source/usage.md +++ b/docs/source/usage.md @@ -229,6 +229,18 @@ The full set of configuration options are: username, you must grant the app `Mail.ReadWrite.Shared`. ::: + :::{tip} + When configuring folder names (e.g., `reports_folder`, `archive_folder`), + you can use standard folder names like `Inbox`, `Archive`, `Sent Items`, etc. + These will be automatically mapped to Microsoft Graph's well-known folder names, + which works reliably across different mailbox locales and avoids issues with + uninitialized or shared mailboxes. Supported folder names include: + - English: Inbox, Sent Items, Deleted Items, Drafts, Junk Email, Archive, Outbox + - German: Posteingang, Gesendete Elemente, Gelöschte Elemente, Entwürfe, Junk-E-Mail, Archiv + - French: Boîte de réception, Éléments envoyés, Éléments supprimés, Brouillons, Courrier indésirable, Archives + - Spanish: Bandeja de entrada, Elementos enviados, Elementos eliminados, Borradores, Correo no deseado + ::: + :::{warning} If you are using the `ClientSecret` auth method, you need to grant the `Mail.ReadWrite` (application) permission to the diff --git a/parsedmarc/mail/graph.py b/parsedmarc/mail/graph.py index e87ac7a3..fa6245a7 100644 --- a/parsedmarc/mail/graph.py +++ b/parsedmarc/mail/graph.py @@ -20,6 +20,59 @@ from parsedmarc.log import logger from parsedmarc.mail.mailbox_connection import MailboxConnection +# Mapping of common folder names to Microsoft Graph well-known folder names +# This avoids the "Default folder Root not found" error on uninitialized mailboxes +WELL_KNOWN_FOLDER_MAP = { + # English names + "inbox": "inbox", + "sent items": "sentitems", + "sent": "sentitems", + "sentitems": "sentitems", + "deleted items": "deleteditems", + "deleted": "deleteditems", + "deleteditems": "deleteditems", + "trash": "deleteditems", + "drafts": "drafts", + "junk email": "junkemail", + "junk": "junkemail", + "junkemail": "junkemail", + "spam": "junkemail", + "archive": "archive", + "outbox": "outbox", + "conversation history": "conversationhistory", + "conversationhistory": "conversationhistory", + # German names + "posteingang": "inbox", + "gesendete elemente": "sentitems", + "gesendet": "sentitems", + "gelöschte elemente": "deleteditems", + "gelöscht": "deleteditems", + "entwürfe": "drafts", + "junk-e-mail": "junkemail", + "archiv": "archive", + "postausgang": "outbox", + # French names + "boîte de réception": "inbox", + "éléments envoyés": "sentitems", + "envoyés": "sentitems", + "éléments supprimés": "deleteditems", + "supprimés": "deleteditems", + "brouillons": "drafts", + "courrier indésirable": "junkemail", + "archives": "archive", + "boîte d'envoi": "outbox", + # Spanish names + "bandeja de entrada": "inbox", + "elementos enviados": "sentitems", + "enviados": "sentitems", + "elementos eliminados": "deleteditems", + "eliminados": "deleteditems", + "borradores": "drafts", + "correo no deseado": "junkemail", + "archivar": "archive", + "bandeja de salida": "outbox", +} + class AuthMethod(Enum): DeviceCode = 1 @@ -130,6 +183,13 @@ def __init__( self.mailbox_name = mailbox def create_folder(self, folder_name: str): + # Check if this is a well-known folder - they already exist and cannot be created + if "/" not in folder_name: + well_known_name = WELL_KNOWN_FOLDER_MAP.get(folder_name.lower()) + if well_known_name: + logger.debug(f"Folder '{folder_name}' is a well-known folder, skipping creation") + return + sub_url = "" path_parts = folder_name.split("/") if len(path_parts) > 1: # Folder is a subFolder @@ -246,6 +306,12 @@ def _find_folder_id_from_folder_path(self, folder_name: str) -> str: parent_folder_id = folder_id return self._find_folder_id_with_parent(path_parts[-1], parent_folder_id) else: + # Check if this is a well-known folder name (case-insensitive) + well_known_name = WELL_KNOWN_FOLDER_MAP.get(folder_name.lower()) + if well_known_name: + # Use well-known folder name directly to avoid querying uninitialized mailboxes + logger.debug(f"Using well-known folder name '{well_known_name}' for '{folder_name}'") + return well_known_name return self._find_folder_id_with_parent(folder_name, None) def _find_folder_id_with_parent( diff --git a/tests.py b/tests.py index d24f6d48..408b3029 100755 --- a/tests.py +++ b/tests.py @@ -156,6 +156,32 @@ def testSmtpTlsSamples(self): parsedmarc.parsed_smtp_tls_reports_to_csv(parsed_report) print("Passed!") + def testMSGraphWellKnownFolders(self): + """Test MSGraph well-known folder name mapping""" + from parsedmarc.mail.graph import WELL_KNOWN_FOLDER_MAP + + # Test English folder names + assert WELL_KNOWN_FOLDER_MAP.get("inbox") == "inbox" + assert WELL_KNOWN_FOLDER_MAP.get("sent items") == "sentitems" + assert WELL_KNOWN_FOLDER_MAP.get("deleted items") == "deleteditems" + assert WELL_KNOWN_FOLDER_MAP.get("archive") == "archive" + + # Test case insensitivity - simulating how the code actually uses it + # This is what happens when user config has "reports_folder = Inbox" + assert WELL_KNOWN_FOLDER_MAP.get("inbox") == "inbox" + assert WELL_KNOWN_FOLDER_MAP.get("Inbox".lower()) == "inbox" # User's exact config + assert WELL_KNOWN_FOLDER_MAP.get("INBOX".lower()) == "inbox" + assert WELL_KNOWN_FOLDER_MAP.get("Archive".lower()) == "archive" + + # Test German folder names + assert WELL_KNOWN_FOLDER_MAP.get("posteingang") == "inbox" + assert WELL_KNOWN_FOLDER_MAP.get("Posteingang".lower()) == "inbox" # Capitalized + assert WELL_KNOWN_FOLDER_MAP.get("archiv") == "archive" + + # Test that custom folders don't match + assert WELL_KNOWN_FOLDER_MAP.get("custom_folder") is None + assert WELL_KNOWN_FOLDER_MAP.get("my_reports") is None + if __name__ == "__main__": unittest.main(verbosity=2)