feat(microsoft-exchange-mail): send mail from Exchange Online over Graph (1.0.0)
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>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
id: microsoft_exchange_mail
|
||||
name: Microsoft Exchange Online Mail
|
||||
version: 1.0.0
|
||||
description: "Send email from an Exchange Online mailbox through Microsoft Graph — plain-text and HTML bodies (inline data-URI images embedded), attachments of any size, CC/BCC, Reply-To and importance; reply, reply-all and forward an existing message. App-only OAuth 2.0 (tenant ID, client ID, client secret); stdlib-only, no extra Python dependencies."
|
||||
changelog: "1.0.0 — Initial release: send-mail (text/HTML, inline images, attachments, CC/BCC/Reply-To, importance), reply-mail, forward-mail and a connection test that reads the permissions granted to the app."
|
||||
category: email
|
||||
|
||||
# Per-instance configuration. App-only (client-credentials) OAuth 2.0 against
|
||||
# Microsoft Graph. The app registration needs the Graph *application*
|
||||
# permission Mail.Send, with admin consent. Mail.Send lets the app send as any
|
||||
# mailbox of the tenant: restrict it to the sending mailbox(es) with an Exchange
|
||||
# application access policy (New-ApplicationAccessPolicy -AccessRight
|
||||
# RestrictAccess) or RBAC for Applications.
|
||||
# Two optional permissions widen what the commands accept:
|
||||
# - Mail.ReadWrite: attachments over 3 MB in total (draft + upload session).
|
||||
# - Mail.ReadBasic.All: reply/forward found by internet_message_id instead of
|
||||
# the Graph message id.
|
||||
config_schema:
|
||||
properties:
|
||||
tenant_id:
|
||||
type: string
|
||||
description: "Directory (tenant) ID of the app registration"
|
||||
client_id:
|
||||
type: string
|
||||
description: "Application (client) ID of the app registration"
|
||||
client_secret:
|
||||
type: string
|
||||
description: "Client secret value of the app registration"
|
||||
x-soar-sensitive: true
|
||||
sender_mailbox:
|
||||
type: string
|
||||
description: "Default sending mailbox (UPN or SMTP address, e.g. soc@contoso.com) — commands may override it with `mailbox`"
|
||||
save_to_sent_items:
|
||||
type: boolean
|
||||
description: "Keep a copy of sent mail in the mailbox's Sent Items"
|
||||
default: true
|
||||
required:
|
||||
- tenant_id
|
||||
- client_id
|
||||
- client_secret
|
||||
- sender_mailbox
|
||||
|
||||
commands:
|
||||
- id: send_mail
|
||||
name: exchange-send-mail
|
||||
description: "Send an email from the configured mailbox (or `mailbox`). At least one recipient (to/cc/bcc) is required. Inline data-URI images in html_body are embedded; attachments over 3 MB in total go through a draft and need Mail.ReadWrite."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
to: { type: string, description: "Recipient(s) for the To field, comma- or semicolon-separated" }
|
||||
cc: { type: string, description: "CC recipient(s), comma- or semicolon-separated" }
|
||||
bcc: { type: string, description: "BCC recipient(s), comma- or semicolon-separated" }
|
||||
subject: { type: string, description: "Subject" }
|
||||
body: { type: string, description: "Plain-text body (ignored when html_body is given)" }
|
||||
html_body: { type: string, description: "HTML body (inline data-URI images are extracted and embedded)" }
|
||||
reply_to: { type: string, description: "Reply-To address(es), comma-separated" }
|
||||
importance: { type: string, enum: [low, normal, high], description: "Message importance (default normal)" }
|
||||
mailbox: { type: string, description: "Send from this mailbox instead of the configured one" }
|
||||
attachment_name: { type: string, description: "Attachment file name (with attachment_base64)" }
|
||||
attachment_base64: { type: string, description: "Attachment content, base64-encoded" }
|
||||
attachments: { type: string, description: "Several attachments as a JSON array: [{\"name\": \"a.pdf\", \"content_base64\": \"...\", \"content_type\": \"application/pdf\"}]" }
|
||||
save_to_sent_items: { type: boolean, description: "Override the instance setting for this message" }
|
||||
required: []
|
||||
outputs_schema:
|
||||
properties:
|
||||
sent: { type: boolean }
|
||||
mailbox: { type: string }
|
||||
recipients: { type: array }
|
||||
attachments: { type: number }
|
||||
|
||||
- id: reply_mail
|
||||
name: exchange-reply-mail
|
||||
description: "Reply (or reply to all) to a message of the mailbox. Give the Graph message_id, or the internet_message_id (needs Mail.ReadBasic.All). The original message is quoted below the reply by Exchange."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
message_id: { type: string, description: "Graph id of the message to answer" }
|
||||
internet_message_id: { type: string, description: "Internet Message-ID header of the message (<...@...>), used when message_id is empty" }
|
||||
body: { type: string, description: "Plain-text reply" }
|
||||
html_body: { type: string, description: "HTML reply (takes precedence over body)" }
|
||||
reply_all: { type: boolean, description: "Answer every recipient of the original message (default false)" }
|
||||
cc: { type: string, description: "Extra CC recipient(s), comma-separated" }
|
||||
mailbox: { type: string, description: "Mailbox holding the message (default: the configured one)" }
|
||||
required: []
|
||||
outputs_schema:
|
||||
properties:
|
||||
sent: { type: boolean }
|
||||
message_id: { type: string }
|
||||
|
||||
- id: forward_mail
|
||||
name: exchange-forward-mail
|
||||
description: "Forward a message of the mailbox, with an optional comment above it. Give the Graph message_id, or the internet_message_id (needs Mail.ReadBasic.All)."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
message_id: { type: string, description: "Graph id of the message to forward" }
|
||||
internet_message_id: { type: string, description: "Internet Message-ID header of the message (<...@...>), used when message_id is empty" }
|
||||
to: { type: string, description: "Recipient(s), comma- or semicolon-separated" }
|
||||
comment: { type: string, description: "Text placed above the forwarded message" }
|
||||
mailbox: { type: string, description: "Mailbox holding the message (default: the configured one)" }
|
||||
required: [to]
|
||||
outputs_schema:
|
||||
properties:
|
||||
sent: { type: boolean }
|
||||
message_id: { type: string }
|
||||
|
||||
- id: test_connection
|
||||
name: exchange-test-connection
|
||||
description: "Obtain a Graph token and check the app holds Mail.Send, without sending mail (used by the Test button). Reports the optional permissions too."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
@@ -0,0 +1,153 @@
|
||||
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):
|
||||
to = recipients(inputs.get("to"))
|
||||
if not to:
|
||||
raise Exception("to is required")
|
||||
mailbox, message_id = resolve_message(cfg, token, inputs)
|
||||
graph("POST", "/users/" + q(mailbox) + "/messages/" + q(message_id) + "/forward", token,
|
||||
body={"comment": str(inputs.get("comment") or ""), "toRecipients": to})
|
||||
return {"sent": True, "mailbox": mailbox, "message_id": message_id,
|
||||
"recipients": [x["emailAddress"]["address"] for x in to]}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,164 @@
|
||||
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)
|
||||
@@ -0,0 +1,292 @@
|
||||
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"]
|
||||
|
||||
|
||||
# sendMail takes the whole message as one JSON request, capped at 4 MB by
|
||||
# Graph. Above this many raw attachment bytes the message goes through a draft
|
||||
# instead, with large files sent through an upload session.
|
||||
DIRECT_LIMIT = 3 * 1024 * 1024
|
||||
SMALL_ATTACHMENT = 3 * 1024 * 1024
|
||||
CHUNK = 320 * 1024 * 10
|
||||
|
||||
|
||||
def _b64(s, what):
|
||||
try:
|
||||
return base64.b64decode(re.sub(r"\s+", "", str(s)), validate=True)
|
||||
except Exception:
|
||||
raise Exception(what + " is not valid base64")
|
||||
|
||||
|
||||
def inline_images(html_body):
|
||||
"""Swap data-URI images for cid: references; return (html, attachments)."""
|
||||
atts = []
|
||||
out, last = "", 0
|
||||
for i, m in enumerate(re.finditer(r'<img[^>]+?src=["\'](data:(image/[a-zA-Z0-9.+-]+);base64,([a-zA-Z0-9+/=\s]+?))["\']', html_body, re.I)):
|
||||
subtype = m.group(2).split("/", 1)[1]
|
||||
cid = "image" + str(i) + "." + subtype + "@riposte"
|
||||
atts.append({
|
||||
"name": "image" + str(i) + "." + subtype,
|
||||
"content_type": m.group(2),
|
||||
"data": _b64(m.group(3), "inline image " + str(i)),
|
||||
"inline": True,
|
||||
"cid": cid,
|
||||
})
|
||||
out += html_body[last:m.start(1)] + "cid:" + cid
|
||||
last = m.end(1)
|
||||
return out + html_body[last:], atts
|
||||
|
||||
|
||||
def file_attachments(inputs):
|
||||
atts = []
|
||||
if inputs.get("attachment_name") and inputs.get("attachment_base64"):
|
||||
atts.append({"name": str(inputs["attachment_name"]), "data": _b64(inputs["attachment_base64"], "attachment_base64")})
|
||||
raw = inputs.get("attachments")
|
||||
if raw:
|
||||
items = raw if isinstance(raw, list) else json.loads(raw)
|
||||
if not isinstance(items, list):
|
||||
raise Exception("attachments must be a JSON array")
|
||||
for i, it in enumerate(items):
|
||||
name = it.get("name")
|
||||
content = it.get("content_base64") or it.get("contentBytes")
|
||||
if not name or not content:
|
||||
raise Exception("attachments[" + str(i) + "] needs name and content_base64")
|
||||
atts.append({"name": str(name), "content_type": it.get("content_type"), "data": _b64(content, "attachments[" + str(i) + "]")})
|
||||
return atts
|
||||
|
||||
|
||||
def graph_attachment(a):
|
||||
g = {
|
||||
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||
"name": a["name"],
|
||||
"contentBytes": base64.b64encode(a["data"]).decode("ascii"),
|
||||
}
|
||||
if a.get("content_type"):
|
||||
g["contentType"] = a["content_type"]
|
||||
if a.get("inline"):
|
||||
g["isInline"] = True
|
||||
g["contentId"] = a["cid"]
|
||||
return g
|
||||
|
||||
|
||||
def upload_large(token, mailbox, draft_id, a):
|
||||
item = {"attachmentType": "file", "name": a["name"], "size": len(a["data"])}
|
||||
if a.get("content_type"):
|
||||
item["contentType"] = a["content_type"]
|
||||
if a.get("inline"):
|
||||
item["isInline"] = True
|
||||
item["contentId"] = a["cid"]
|
||||
sess = graph("POST", "/users/" + q(mailbox) + "/messages/" + q(draft_id) + "/attachments/createUploadSession",
|
||||
token, body={"AttachmentItem": item})
|
||||
url, data, total = sess["uploadUrl"], a["data"], len(a["data"])
|
||||
# The upload URL is pre-authenticated: sending the bearer token there is refused.
|
||||
for start in range(0, total, CHUNK):
|
||||
chunk = data[start:start + CHUNK]
|
||||
end = start + len(chunk) - 1
|
||||
req = urllib.request.Request(url, data=chunk, method="PUT", headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(len(chunk)),
|
||||
"Content-Range": "bytes " + str(start) + "-" + str(end) + "/" + str(total),
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=300) as r:
|
||||
r.read()
|
||||
|
||||
|
||||
def main(cfg, token, inputs):
|
||||
mailbox = _mailbox(cfg, inputs)
|
||||
to, cc, bcc = recipients(inputs.get("to")), recipients(inputs.get("cc")), recipients(inputs.get("bcc"))
|
||||
if not (to or cc or bcc):
|
||||
raise Exception("at least one recipient (to, cc or bcc) is required")
|
||||
|
||||
html_body = str(inputs.get("html_body") or "")
|
||||
atts = []
|
||||
if html_body:
|
||||
html_body, atts = inline_images(html_body)
|
||||
body = {"contentType": "HTML", "content": html_body}
|
||||
else:
|
||||
body = {"contentType": "Text", "content": str(inputs.get("body") or "")}
|
||||
atts += file_attachments(inputs)
|
||||
|
||||
message = {"subject": str(inputs.get("subject") or ""), "body": body}
|
||||
if to:
|
||||
message["toRecipients"] = to
|
||||
if cc:
|
||||
message["ccRecipients"] = cc
|
||||
if bcc:
|
||||
message["bccRecipients"] = bcc
|
||||
if inputs.get("reply_to"):
|
||||
message["replyTo"] = recipients(inputs["reply_to"])
|
||||
importance = str(inputs.get("importance") or "").strip().lower()
|
||||
if importance:
|
||||
if importance not in ("low", "normal", "high"):
|
||||
raise Exception("importance must be low, normal or high")
|
||||
message["importance"] = importance
|
||||
|
||||
save = _flag(inputs.get("save_to_sent_items"), _flag(cfg.get("save_to_sent_items"), True))
|
||||
total = sum(len(a["data"]) for a in atts)
|
||||
every = [x["emailAddress"]["address"] for x in to + cc + bcc]
|
||||
base = "/users/" + q(mailbox)
|
||||
|
||||
if total <= DIRECT_LIMIT:
|
||||
if atts:
|
||||
message["attachments"] = [graph_attachment(a) for a in atts]
|
||||
graph("POST", base + "/sendMail", token, body={"message": message, "saveToSentItems": save})
|
||||
return {"sent": True, "mailbox": mailbox, "recipients": every, "attachments": len(atts)}
|
||||
|
||||
# Large message: draft, attachments one by one, then send. A sent draft is
|
||||
# always filed in Sent Items, whatever save_to_sent_items says.
|
||||
draft = graph("POST", base + "/messages", token, body=message)
|
||||
draft_id = draft["id"]
|
||||
try:
|
||||
for a in atts:
|
||||
if len(a["data"]) < SMALL_ATTACHMENT:
|
||||
graph("POST", base + "/messages/" + q(draft_id) + "/attachments", token, body=graph_attachment(a))
|
||||
else:
|
||||
upload_large(token, mailbox, draft_id, a)
|
||||
graph("POST", base + "/messages/" + q(draft_id) + "/send", token)
|
||||
except Exception:
|
||||
try:
|
||||
graph("DELETE", base + "/messages/" + q(draft_id), token)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
return {"sent": True, "mailbox": mailbox, "recipients": every, "attachments": len(atts), "via_draft": True}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,166 @@
|
||||
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 _roles(token):
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload)).get("roles") or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def main(cfg, token, inputs):
|
||||
mailbox = _mailbox(cfg, inputs)
|
||||
roles = _roles(token)
|
||||
if "Mail.Send" not in roles:
|
||||
raise Exception("token obtained, but the app does not hold the Graph application permission Mail.Send "
|
||||
"(granted: " + (", ".join(roles) or "none") + "). Add it as an Application permission and grant admin consent.")
|
||||
return {
|
||||
"ok": True,
|
||||
"sender_mailbox": mailbox,
|
||||
"roles": roles,
|
||||
"large_attachments": "Mail.ReadWrite" in roles,
|
||||
"find_by_internet_message_id": any(r in roles for r in ("Mail.ReadBasic.All", "Mail.ReadBasic", "Mail.Read", "Mail.ReadWrite")),
|
||||
}
|
||||
|
||||
|
||||
_run(main)
|
||||
Reference in New Issue
Block a user