|
1 | | -import requests |
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Send an authorized email through Microsoft Graph using app-only authentication. |
| 3 | +
|
| 4 | +The client secret is read from the ENTRA_CLIENT_SECRET environment variable and is |
| 5 | +never stored in this source file. The Entra application requires Microsoft Graph |
| 6 | +Mail.Send application permission with administrator consent. Exchange Online App |
| 7 | +RBAC should be used to restrict the application to approved sender mailboxes. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import argparse |
2 | 13 | import json |
| 14 | +import os |
| 15 | +import sys |
| 16 | +from typing import Any |
| 17 | +from urllib.parse import quote |
| 18 | + |
| 19 | +import requests |
| 20 | + |
| 21 | +TOKEN_ENDPOINT = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" |
| 22 | +SEND_MAIL_ENDPOINT = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail" |
| 23 | +GRAPH_SCOPE = "https://graph.microsoft.com/.default" |
| 24 | + |
3 | 25 |
|
4 | | -# Replace these values with your own |
5 | | -tenant_id = "xx" |
6 | | -client_id = "xx" |
7 | | -client_secret = "xx" |
8 | | -from_user = "mcontestabile@xx" # Sender's email |
9 | | -to_user = "mcontestabile@xx" # Recipient's email |
10 | | - |
11 | | -# Get an access token |
12 | | -token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" |
13 | | -token_data = { |
14 | | - "grant_type": "client_credentials", |
15 | | - "client_id": client_id, |
16 | | - "client_secret": client_secret, |
17 | | - "scope": "https://graph.microsoft.com/.default" |
18 | | -} |
19 | | -token_response = requests.post(token_url, data=token_data) |
20 | | -access_token = token_response.json().get("access_token") |
21 | | - |
22 | | -if not access_token: |
23 | | - print("Failed to obtain access token.") |
24 | | - exit(1) |
25 | | - |
26 | | -# Send an email using Microsoft Graph API |
27 | | -graph_url = f"https://graph.microsoft.com/v1.0/users/{from_user}/sendMail" |
28 | | -email_body = { |
29 | | - "message": { |
30 | | - "subject": "Test Email from Service Principal", |
31 | | - "body": { |
32 | | - "contentType": "Text", |
33 | | - "content": "This is a test email sent by a service principal." |
| 26 | +def parse_arguments() -> argparse.Namespace: |
| 27 | + parser = argparse.ArgumentParser( |
| 28 | + description="Send an authorized email through Microsoft Graph." |
| 29 | + ) |
| 30 | + parser.add_argument("--tenant-id", required=True, help="Microsoft Entra tenant ID.") |
| 31 | + parser.add_argument("--client-id", required=True, help="Application client ID.") |
| 32 | + parser.add_argument("--sender", required=True, help="Authorized sender UPN or user ID.") |
| 33 | + parser.add_argument("--recipient", required=True, help="Recipient email address.") |
| 34 | + parser.add_argument("--subject", required=True, help="Email subject.") |
| 35 | + parser.add_argument("--body", required=True, help="Plain-text email body.") |
| 36 | + parser.add_argument( |
| 37 | + "--no-save-to-sent-items", |
| 38 | + action="store_true", |
| 39 | + help="Do not save the message in the sender's Sent Items folder.", |
| 40 | + ) |
| 41 | + parser.add_argument( |
| 42 | + "--dry-run", |
| 43 | + action="store_true", |
| 44 | + help="Validate inputs and print a redacted request summary without authenticating or sending.", |
| 45 | + ) |
| 46 | + parser.add_argument( |
| 47 | + "--acknowledge-authorized-mailbox", |
| 48 | + action="store_true", |
| 49 | + required=True, |
| 50 | + help="Confirm that the application is authorized to send from the selected mailbox.", |
| 51 | + ) |
| 52 | + return parser.parse_args() |
| 53 | + |
| 54 | + |
| 55 | +def require_client_secret() -> str: |
| 56 | + secret = os.getenv("ENTRA_CLIENT_SECRET") |
| 57 | + if not secret: |
| 58 | + raise RuntimeError( |
| 59 | + "ENTRA_CLIENT_SECRET is not set. Store the app secret in the environment, " |
| 60 | + "not in the script or command history." |
| 61 | + ) |
| 62 | + return secret |
| 63 | + |
| 64 | + |
| 65 | +def get_access_token( |
| 66 | + session: requests.Session, |
| 67 | + tenant_id: str, |
| 68 | + client_id: str, |
| 69 | + client_secret: str, |
| 70 | +) -> str: |
| 71 | + response = session.post( |
| 72 | + TOKEN_ENDPOINT.format(tenant_id=quote(tenant_id, safe="")), |
| 73 | + data={ |
| 74 | + "grant_type": "client_credentials", |
| 75 | + "client_id": client_id, |
| 76 | + "client_secret": client_secret, |
| 77 | + "scope": GRAPH_SCOPE, |
34 | 78 | }, |
35 | | - "toRecipients": [ |
36 | | - { |
37 | | - "emailAddress": { |
38 | | - "address": to_user |
39 | | - } |
40 | | - } |
41 | | - ] |
| 79 | + timeout=30, |
| 80 | + ) |
| 81 | + |
| 82 | + if not response.ok: |
| 83 | + raise RuntimeError(format_graph_error("Token request failed", response)) |
| 84 | + |
| 85 | + payload = response.json() |
| 86 | + token = payload.get("access_token") |
| 87 | + if not token: |
| 88 | + raise RuntimeError("Token response did not contain an access_token.") |
| 89 | + return str(token) |
| 90 | + |
| 91 | + |
| 92 | +def build_message(args: argparse.Namespace) -> dict[str, Any]: |
| 93 | + return { |
| 94 | + "message": { |
| 95 | + "subject": args.subject, |
| 96 | + "body": {"contentType": "Text", "content": args.body}, |
| 97 | + "toRecipients": [ |
| 98 | + {"emailAddress": {"address": args.recipient}} |
| 99 | + ], |
| 100 | + }, |
| 101 | + "saveToSentItems": not args.no_save_to_sent_items, |
42 | 102 | } |
43 | | -} |
44 | 103 |
|
45 | | -headers = { |
46 | | - "Authorization": f"Bearer {access_token}", |
47 | | - "Content-Type": "application/json" |
48 | | -} |
49 | 104 |
|
50 | | -response = requests.post(graph_url, headers=headers, json=email_body) |
| 105 | +def send_message( |
| 106 | + session: requests.Session, |
| 107 | + access_token: str, |
| 108 | + sender: str, |
| 109 | + message: dict[str, Any], |
| 110 | +) -> None: |
| 111 | + response = session.post( |
| 112 | + SEND_MAIL_ENDPOINT.format(sender=quote(sender, safe="")), |
| 113 | + headers={ |
| 114 | + "Authorization": f"Bearer {access_token}", |
| 115 | + "Content-Type": "application/json", |
| 116 | + }, |
| 117 | + json=message, |
| 118 | + timeout=30, |
| 119 | + ) |
| 120 | + |
| 121 | + if response.status_code != 202: |
| 122 | + raise RuntimeError(format_graph_error("Send-mail request failed", response)) |
| 123 | + |
| 124 | + |
| 125 | +def format_graph_error(prefix: str, response: requests.Response) -> str: |
| 126 | + request_id = response.headers.get("request-id") or response.headers.get("client-request-id") |
| 127 | + try: |
| 128 | + payload = response.json() |
| 129 | + error = payload.get("error", payload) |
| 130 | + code = error.get("code") if isinstance(error, dict) else None |
| 131 | + message = error.get("message") if isinstance(error, dict) else json.dumps(payload) |
| 132 | + except (ValueError, TypeError): |
| 133 | + code = None |
| 134 | + message = response.text.strip() or "No response body" |
| 135 | + |
| 136 | + details = [prefix, f"HTTP {response.status_code}"] |
| 137 | + if code: |
| 138 | + details.append(f"code={code}") |
| 139 | + if request_id: |
| 140 | + details.append(f"request-id={request_id}") |
| 141 | + details.append(f"message={message}") |
| 142 | + return " | ".join(details) |
| 143 | + |
| 144 | + |
| 145 | +def main() -> int: |
| 146 | + args = parse_arguments() |
| 147 | + message = build_message(args) |
| 148 | + |
| 149 | + if args.dry_run: |
| 150 | + summary = { |
| 151 | + "tenant_id": args.tenant_id, |
| 152 | + "client_id": args.client_id, |
| 153 | + "sender": args.sender, |
| 154 | + "recipient": args.recipient, |
| 155 | + "subject": args.subject, |
| 156 | + "save_to_sent_items": not args.no_save_to_sent_items, |
| 157 | + "body_length": len(args.body), |
| 158 | + } |
| 159 | + print(json.dumps(summary, indent=2)) |
| 160 | + print("Dry run complete. No authentication was attempted and no email was sent.") |
| 161 | + return 0 |
| 162 | + |
| 163 | + client_secret = require_client_secret() |
| 164 | + |
| 165 | + with requests.Session() as session: |
| 166 | + access_token = get_access_token( |
| 167 | + session=session, |
| 168 | + tenant_id=args.tenant_id, |
| 169 | + client_id=args.client_id, |
| 170 | + client_secret=client_secret, |
| 171 | + ) |
| 172 | + send_message( |
| 173 | + session=session, |
| 174 | + access_token=access_token, |
| 175 | + sender=args.sender, |
| 176 | + message=message, |
| 177 | + ) |
| 178 | + |
| 179 | + print("Microsoft Graph accepted the message (HTTP 202). Delivery is not guaranteed by this response.") |
| 180 | + return 0 |
| 181 | + |
51 | 182 |
|
52 | | -if response.status_code == 202: |
53 | | - print("Email sent successfully!") |
54 | | -else: |
55 | | - print(f"Failed to send email. Status code: {response.status_code}") |
56 | | - print(response.json()) |
| 183 | +if __name__ == "__main__": |
| 184 | + try: |
| 185 | + raise SystemExit(main()) |
| 186 | + except (requests.RequestException, RuntimeError) as exc: |
| 187 | + print(f"Error: {exc}", file=sys.stderr) |
| 188 | + raise SystemExit(1) |
0 commit comments