c358814fe7
Mail Sender speaks SMTP and Defender for Office 365 only reads and
purges, so nothing in the catalogue could send from an Exchange Online
mailbox with an app registration (tenant ID, client ID, client secret).
send_mail posts to /users/{mailbox}/sendMail: text or HTML, inline
data-URI images as cid attachments, CC/BCC, Reply-To, importance and
saveToSentItems. Over 3 MB of attachments the message goes through a
draft and upload sessions instead, since sendMail caps the request at
4 MB; that path needs Mail.ReadWrite. reply_mail and forward_mail take
the Graph id or the internet Message-ID (resolved with a $filter, which
needs Mail.ReadBasic.All). test_connection reads the token's roles and
fails when Mail.Send is missing, without sending anything.
Exercised against a stubbed Graph: request bodies, the draft path with a
7 MB file in three ranges and no bearer on the upload URL, and Riposte's
ParseManifest. Not yet tried against a real tenant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
5.9 KiB
Python
165 lines
5.9 KiB
Python
import base64, json, os, re, sys, urllib.parse, urllib.request, urllib.error
|
|
|
|
GRAPH = "https://graph.microsoft.com/v1.0"
|
|
|
|
|
|
def _cfg():
|
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
|
|
|
|
|
def _inputs():
|
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
|
|
|
|
|
def _token(cfg):
|
|
tenant = str(cfg.get("tenant_id", "")).strip()
|
|
url = "https://login.microsoftonline.com/" + urllib.parse.quote(tenant, safe="") + "/oauth2/v2.0/token"
|
|
form = urllib.parse.urlencode({
|
|
"grant_type": "client_credentials",
|
|
"client_id": str(cfg.get("client_id", "")).strip(),
|
|
"client_secret": str(cfg.get("client_secret", "")),
|
|
"scope": "https://graph.microsoft.com/.default",
|
|
}).encode("utf-8")
|
|
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
tok = json.loads(r.read())
|
|
except urllib.error.HTTPError as e:
|
|
detail = e.read().decode("utf-8", "replace")
|
|
try:
|
|
detail = json.loads(detail).get("error_description", detail)
|
|
except Exception:
|
|
pass
|
|
raise Exception("Token request refused (HTTP " + str(e.code) + "): " + str(detail).splitlines()[0])
|
|
if not tok.get("access_token"):
|
|
raise Exception("Token request failed: " + json.dumps(tok))
|
|
return tok["access_token"]
|
|
|
|
|
|
def graph(method, path, token, body=None, params=None):
|
|
url = GRAPH + path
|
|
if params:
|
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
|
if clean:
|
|
url += "?" + urllib.parse.urlencode(clean)
|
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
if data is None and method == "POST":
|
|
data = b"" # Graph answers 411 to a POST without Content-Length (…/send)
|
|
headers = {"Authorization": "Bearer " + token, "Accept": "application/json"}
|
|
if data is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
with urllib.request.urlopen(req, timeout=120) as r:
|
|
raw = r.read()
|
|
return json.loads(raw) if raw else {}
|
|
|
|
|
|
def _hint(code, detail):
|
|
if code == 403 or "ErrorAccessDenied" in detail:
|
|
return "The app lacks the Graph application permission for this call (Mail.Send, with admin consent), or an Exchange application access policy excludes this mailbox."
|
|
if code == 404 or "ErrorInvalidUser" in detail or "MailboxNotEnabledForRESTAPI" in detail:
|
|
return "Mailbox not found in Exchange Online: check the sending mailbox (UPN or primary SMTP address)."
|
|
return None
|
|
|
|
|
|
def _run(fn):
|
|
try:
|
|
cfg = _cfg()
|
|
inputs = _inputs()
|
|
token = _token(cfg)
|
|
print(json.dumps(fn(cfg, token, inputs)))
|
|
except urllib.error.HTTPError as e:
|
|
detail = e.read().decode("utf-8", "replace")
|
|
out = {"error": "HTTP " + str(e.code), "detail": detail}
|
|
hint = _hint(e.code, detail)
|
|
if hint:
|
|
out["hint"] = hint
|
|
print(json.dumps(out))
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(json.dumps({"error": str(e)}))
|
|
sys.exit(1)
|
|
|
|
|
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
|
|
|
|
|
def _flag(v, default):
|
|
if v is None or v == "":
|
|
return default
|
|
if isinstance(v, bool):
|
|
return v
|
|
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
|
|
|
|
|
def _mailbox(cfg, inputs):
|
|
mailbox = str(inputs.get("mailbox") or cfg.get("sender_mailbox") or "").strip()
|
|
if not mailbox:
|
|
raise Exception("no mailbox (set sender_mailbox in the instance or pass mailbox)")
|
|
return mailbox
|
|
|
|
|
|
def recipients(s):
|
|
"""'a@x.com, Bob <b@x.com>; c@x.com' -> Graph recipient objects."""
|
|
out = []
|
|
for part in re.split(r"[,;]", str(s or "")):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
m = re.match(r'^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$', part)
|
|
if m:
|
|
addr = {"address": m.group(2).strip()}
|
|
if m.group(1).strip():
|
|
addr["name"] = m.group(1).strip()
|
|
else:
|
|
addr = {"address": part}
|
|
out.append({"emailAddress": addr})
|
|
return out
|
|
|
|
|
|
def resolve_message(cfg, token, inputs):
|
|
"""Return (mailbox, Graph message id) from message_id or internet_message_id."""
|
|
mailbox = _mailbox(cfg, inputs)
|
|
message_id = str(inputs.get("message_id") or "").strip()
|
|
if message_id:
|
|
return mailbox, message_id
|
|
imid = str(inputs.get("internet_message_id") or "").strip()
|
|
if not imid:
|
|
raise Exception("message_id or internet_message_id is required")
|
|
if not imid.startswith("<"):
|
|
imid = "<" + imid + ">"
|
|
res = graph("GET", "/users/" + q(mailbox) + "/messages", token, params={
|
|
"$filter": "internetMessageId eq '" + imid.replace("'", "''") + "'",
|
|
"$select": "id,subject",
|
|
"$top": 1,
|
|
})
|
|
rows = res.get("value") or []
|
|
if not rows:
|
|
raise Exception("no message with internetMessageId " + imid + " in " + mailbox)
|
|
return mailbox, rows[0]["id"]
|
|
|
|
|
|
def main(cfg, token, inputs):
|
|
mailbox, message_id = resolve_message(cfg, token, inputs)
|
|
html_body = str(inputs.get("html_body") or "")
|
|
text = str(inputs.get("body") or "")
|
|
cc = recipients(inputs.get("cc"))
|
|
action = "replyAll" if _flag(inputs.get("reply_all"), False) else "reply"
|
|
|
|
# Graph takes either a comment or message.body, never both.
|
|
payload = {}
|
|
if html_body or cc:
|
|
message = {}
|
|
if html_body:
|
|
message["body"] = {"contentType": "HTML", "content": html_body}
|
|
if cc:
|
|
message["ccRecipients"] = cc
|
|
payload["message"] = message
|
|
if not html_body:
|
|
payload["comment"] = text
|
|
graph("POST", "/users/" + q(mailbox) + "/messages/" + q(message_id) + "/" + action, token, body=payload)
|
|
return {"sent": True, "mailbox": mailbox, "message_id": message_id, "reply_all": action == "replyAll"}
|
|
|
|
|
|
_run(main)
|