diff --git a/integrations/microsoft-exchange-mail/manifest.yaml b/integrations/microsoft-exchange-mail/manifest.yaml new file mode 100644 index 0000000..1fa5f81 --- /dev/null +++ b/integrations/microsoft-exchange-mail/manifest.yaml @@ -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: {} } diff --git a/integrations/microsoft-exchange-mail/scripts/forward_mail.py b/integrations/microsoft-exchange-mail/scripts/forward_mail.py new file mode 100644 index 0000000..e2c7986 --- /dev/null +++ b/integrations/microsoft-exchange-mail/scripts/forward_mail.py @@ -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 ; 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) diff --git a/integrations/microsoft-exchange-mail/scripts/reply_mail.py b/integrations/microsoft-exchange-mail/scripts/reply_mail.py new file mode 100644 index 0000000..05a4ac5 --- /dev/null +++ b/integrations/microsoft-exchange-mail/scripts/reply_mail.py @@ -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 ; 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) diff --git a/integrations/microsoft-exchange-mail/scripts/send_mail.py b/integrations/microsoft-exchange-mail/scripts/send_mail.py new file mode 100644 index 0000000..6bddf01 --- /dev/null +++ b/integrations/microsoft-exchange-mail/scripts/send_mail.py @@ -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 ; 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']+?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) diff --git a/integrations/microsoft-exchange-mail/scripts/test_connection.py b/integrations/microsoft-exchange-mail/scripts/test_connection.py new file mode 100644 index 0000000..eff28ad --- /dev/null +++ b/integrations/microsoft-exchange-mail/scripts/test_connection.py @@ -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 ; 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)