pythonemailmicrosoft-graph-apioffice365smime

S/MIME Email Sent via Python and Microsoft Graph Works in Gmail but Not in Outlook


I was trying to send an S/MIME email created in Python using Microsoft Graph. The email was sent successfully, and both Gmail and the iOS Mail app on my iPhone recognized the sender as trusted with a valid certificate. Everything seemed fine on those platforms.

However, when I opened the same email in Outlook, I encountered the following issues:

  1. Outlook displayed a warning stating that the certificate had an issue and the content may have been altered.
  2. The email content was converted into an attachment named winmail.dat (Content-Type: application/ms-tnef).
  3. The S/MIME signature was not displayed properly, and the email failed DKIM and DMARC checks (dkim=none, dmarc=none).

This behavior is puzzling since the email is properly recognized on other platforms. Is there something specific about the way Outlook processes S/MIME emails that could cause this issue? Could this be related to the MIME encoding or formatting when sending the email via Graph API?


Solution

  • If you're looking to send MIME content (like HTML and plain text) with Microsoft Graph in Python, here’s a guide on how to sign the MIME message with S/MIME and send it to Microsoft Graph. This code demonstrates how to handle OAuth authentication, sign the email using S/MIME, and encode it for Graph API compatibility.

    The process requires:

    1. OAuth Token Retrieval: Use a certificate-based credential to retrieve an access token.
    2. MIME Message Creation: Create a MIME message with alternative plain text and HTML parts.
    3. S/MIME Signing: Sign the MIME message with a private key and certificate.
    4. Draft Creation: Create a draft email with the signed MIME content using Microsoft Graph.

    Here's the full code:

    from azure.identity import CertificateCredential
    from azure.core.credentials import AccessToken
    import base64
    import httpx
    from email.mime.multipart import MIMEMultipart
    from email.mime.text import MIMEText
    from smail import sign_message
    
    # Step 1: Retrieve OAuth token using Certificate-based credential
    def __get_graph_oauth_token(self) -> str:
        credential: CertificateCredential = self.__get_graph_credential()
        token: AccessToken = credential.get_token(self.scope)
        return token.token
    
    def __get_graph_credential(self) -> CertificateCredential:
        crypto_utils: CryptoUtils = CryptoUtils()
        certificate_data: bytes = crypto_utils.get_pfx_certificate(cert_name=self.domain_cert_name)
        return CertificateCredential(
            tenant_id=self.tenant_id,
            client_id=self.client_id,
            certificate_data=certificate_data
        )
    
    # Step 2: Create MIME message with S/MIME signing
    def create_signed_mime_message(self, subject: str, body: str, to_email: str, from_email: str):
        # Retrieve the private key, main certificate, and additional certs in PEM format
        private_key_pem, main_certificate_pem, additional_certs_pem = self.__get_auth_priv_rep_certificate_chain()
    
        # Step 2.1: Create inner alternative part for plain text and HTML
        inner_alternative = MIMEMultipart("alternative")
        inner_alternative.attach(MIMEText(body, "plain", "utf-8"))
        inner_alternative.attach(MIMEText(body, "html", "utf-8"))
        inner_alternative["From"] = from_email
        inner_alternative["To"] = to_email
        inner_alternative["Subject"] = subject
    
        # Step 2.2: Sign the message with temporary key and cert files
        signed_msg = sign_message(
            message=inner_alternative,
            key_signer=private_key_pem,
            cert_signer=main_certificate_pem,
            additional_certs=[additional_certs_pem]
        )
        return signed_msg
    
    # Step 3: Create a draft email with MIME content using Microsoft Graph
    async def create_draft_email_with_mime(self, subject: str, body: str, to_email: str):
        access_token: str = self.__get_graph_oauth_token()
        url: str = f"https://graph.microsoft.com/v1.0/users/{self.from_email}/messages"
    
        headers = {
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "text/plain",
            "Prefer": 'IdType="ImmutableId"'
        }
    
        # Step 3.1: Create and sign MIME message
        signed_mime_message = self.create_signed_mime_message(
            subject=subject,
            body=body,
            to_email=to_email,
            from_email=self.from_email
        )
    
        # **CRUCIAL STEP**: Encode the MIME message in base64 and replace line breaks to ensure compatibility with Graph API
        mime_base64 = base64.encodebytes(signed_mime_message.as_string().encode().replace(b'\n', b'\r\n'))
    
    
        # Step 3.3: Send the signed MIME content as draft to Microsoft Graph
        async with httpx.AsyncClient() as client:
            response = await client.post(url, headers=headers, content=mime_base64)
    
        if response.status_code == 201:
            return response.json()
        else:
            self.logger.error(f"Error creating draft! Status: {response.status_code}. Response: {response.text}")
    

    Explanation

    1. OAuth Token Generation: The function __get_graph_oauth_token fetches the access token required by Microsoft Graph using a certificate-based credential.
    2. S/MIME Signing:
      • The create_signed_mime_message function creates an alternative MIME message with both plain text and HTML parts.
      • This message is then signed using sign_message with the appropriate private key and certificate chain in PEM format.
    3. Crucial Base64 Encoding for Graph:
      • The encoding line mime_base64 = base64.encodebytes(signed_mime_message.as_string().encode().replace(b'\n', b'\r\n')) is essential to ensure compatibility with the Graph API.
      • Microsoft Graph expects the MIME content in base64 with \r\n line endings, so this adjustment is key for successful transmission.
      • If this step is not done correctly, Outlook may not interpret the S/MIME signature as intended. Instead, the email can be converted to the winmail.dat format (Content-Type: application/ms-tnef), which is Outlook’s Rich Text Format. This prevents the S/MIME signature from displaying correctly. Additionally, the authentication results may show dkim=none and dmarc=none, indicating that Outlook did not properly interpret the S/MIME signature.

    Key Points