Skip to content

Commit 7c2522f

Browse files
authored
Add files via upload
1 parent 658a83a commit 7c2522f

1 file changed

Lines changed: 181 additions & 49 deletions

File tree

Azure Active Directory/sendmail.py

Lines changed: 181 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,188 @@
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
213
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+
325

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,
3478
},
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,
42102
}
43-
}
44103

45-
headers = {
46-
"Authorization": f"Bearer {access_token}",
47-
"Content-Type": "application/json"
48-
}
49104

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+
51182

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

Comments
 (0)