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)