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)