From 84be770928915dd1d48a11aa959bffd77d531e8a Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Fri, 10 Jul 2026 23:29:12 +0200 Subject: [PATCH] feat(gmail-single-user): new single-mailbox Gmail integration Gmail for one mailbox over OAuth 2.0 (no service account / delegation): auth-link + exchange-code to obtain a refresh token, connectivity test, message search/get, send/reply with attachments, attachment retrieval, and get_incidents ingestion with an OCSF mapper. Refresh-token grant, stdlib-only (no extra Python dependencies). Co-Authored-By: Claude Fable 5 --- .../gmail-single-user-message.yaml | 3 + integrations/gmail-single-user/manifest.yaml | 168 ++++++++++++++++++ .../mappers/get_incidents.yaml | 15 ++ .../gmail-single-user/scripts/auth_link.py | 43 +++++ .../scripts/exchange_code.py | 45 +++++ .../scripts/get_attachments.py | 88 +++++++++ .../scripts/get_incidents.py | 144 +++++++++++++++ .../gmail-single-user/scripts/get_mail.py | 106 +++++++++++ .../gmail-single-user/scripts/reply_mail.py | 115 ++++++++++++ .../gmail-single-user/scripts/search.py | 128 +++++++++++++ .../gmail-single-user/scripts/send_mail.py | 111 ++++++++++++ .../scripts/test_connection.py | 61 +++++++ 12 files changed, 1027 insertions(+) create mode 100644 integrations/gmail-single-user/incident-types/gmail-single-user-message.yaml create mode 100644 integrations/gmail-single-user/manifest.yaml create mode 100644 integrations/gmail-single-user/mappers/get_incidents.yaml create mode 100644 integrations/gmail-single-user/scripts/auth_link.py create mode 100644 integrations/gmail-single-user/scripts/exchange_code.py create mode 100644 integrations/gmail-single-user/scripts/get_attachments.py create mode 100644 integrations/gmail-single-user/scripts/get_incidents.py create mode 100644 integrations/gmail-single-user/scripts/get_mail.py create mode 100644 integrations/gmail-single-user/scripts/reply_mail.py create mode 100644 integrations/gmail-single-user/scripts/search.py create mode 100644 integrations/gmail-single-user/scripts/send_mail.py create mode 100644 integrations/gmail-single-user/scripts/test_connection.py diff --git a/integrations/gmail-single-user/incident-types/gmail-single-user-message.yaml b/integrations/gmail-single-user/incident-types/gmail-single-user-message.yaml new file mode 100644 index 0000000..8f0b5b1 --- /dev/null +++ b/integrations/gmail-single-user/incident-types/gmail-single-user-message.yaml @@ -0,0 +1,3 @@ +name: "Gmail Single User Message" +color: "#ea4335" +icon: "alert" diff --git a/integrations/gmail-single-user/manifest.yaml b/integrations/gmail-single-user/manifest.yaml new file mode 100644 index 0000000..fe27822 --- /dev/null +++ b/integrations/gmail-single-user/manifest.yaml @@ -0,0 +1,168 @@ +id: gmail_single_user +name: Gmail Single User +version: 1.0.0 +description: "Gmail for a single mailbox over OAuth 2.0 (no service account, no domain-wide delegation) — search/read messages, send and reply (with attachments), retrieve attachments, and ingest messages (get_incidents) with an OCSF mapper. Uses a per-mailbox OAuth refresh token; stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: OAuth authorization link + code exchange, connectivity test, message ingestion with OCSF mapper, send/reply mail with attachments, and attachment retrieval." +category: email + +# Per-instance configuration. This integration authenticates as ONE Gmail user +# via OAuth 2.0 (not a service account). One-time setup: +# 1. Create an OAuth client (type: Web application) in Google Cloud, authorize +# the Gmail scopes, and note the Client ID / Client Secret. Add the redirect +# URI below to the client's authorized redirect URIs. +# 2. Run gmail-single-user-auth-link, open the URL, consent, and copy the +# "code" value from the redirect. +# 3. Run gmail-single-user-exchange-code with that code to get a refresh_token. +# 4. Paste the refresh_token into the config below and save. +# Every command then exchanges the refresh token for a short-lived access token. +config_schema: + properties: + email: + type: string + description: "The Gmail address of the mailbox (used as the default From address)" + client_id: + type: string + description: "OAuth client ID" + client_secret: + type: string + description: "OAuth client secret" + x-soar-sensitive: true + refresh_token: + type: string + description: "OAuth refresh token (obtain it once with gmail-single-user-exchange-code, then paste it here)" + x-soar-sensitive: true + redirect_uri: + type: string + description: "OAuth redirect URI registered on the client (must match at auth-link and exchange-code time)" + default: http://localhost + send_as: + type: string + description: "Optional address to send from (a verified send-as alias of the mailbox)" + required: + - email + - client_id + - client_secret + +commands: + - id: auth_link + name: gmail-single-user-auth-link + description: "Generate the OAuth consent URL. Open it, approve access, and copy the returned code for gmail-single-user-exchange-code." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } + - id: exchange_code + name: gmail-single-user-exchange-code + description: "Exchange an OAuth authorization code for a refresh token (paste the refresh_token into the instance config afterwards)." + inputs_schema: + properties: + code: { type: string, description: "Authorization code from the consent redirect" } + redirect_uri: { type: string, description: "Override the configured redirect URI (must match the one used for auth-link)" } + required: [code] + outputs_schema: { properties: {} } + - id: get_incidents + name: gmail-single-user-get-incidents + description: "Fetch messages from the mailbox for ingestion. Returns {result:[...]}; use result as the alert rule results path." + risk: read + inputs_schema: + properties: + query: { type: string, description: "Extra Gmail search query (same syntax as the Gmail search box)" } + after: { type: string, description: "Lower bound on message date, epoch (s/ms) or ISO8601 (incremental fetch watermark)" } + subject: { type: string, description: "Filter by subject" } + from: { type: string, description: "Filter by sender" } + to: { type: string, description: "Filter by recipient" } + filename: { type: string, description: "Filter by attachment filename or type" } + label_id: { type: string, description: "Restrict to a label ID" } + has_attachments: { type: boolean, description: "Only messages with attachments" } + max: { type: number, description: "Maximum messages to fetch (default 50)" } + required: [] + outputs_schema: { properties: {} } + ingest: + results_path: result + dedup_key: id + incremental_field: after + - id: search + name: gmail-single-user-search + description: "Search the mailbox and return flattened messages (subject/from/to/body/attachments)." + risk: read + inputs_schema: + properties: + query: { type: string, description: "Gmail search query" } + subject: { type: string, description: "Filter by subject" } + from: { type: string, description: "Filter by sender" } + to: { type: string, description: "Filter by recipient" } + filename: { type: string, description: "Filter by attachment filename or type" } + label_id: { type: string, description: "Restrict to a label ID" } + has_attachments: { type: boolean, description: "Only messages with attachments" } + max_results: { type: number, description: "Maximum messages (default 100, max 500)" } + page_token: { type: string, description: "Page token for the next results page" } + include_spam_trash: { type: boolean, description: "Include SPAM and TRASH" } + required: [] + outputs_schema: { properties: {} } + - id: get_mail + name: gmail-single-user-get-mail + description: "Retrieve a single message (flattened) by ID." + risk: read + inputs_schema: + properties: + message_id: { type: string, description: "Message ID" } + required: [message_id] + outputs_schema: { properties: {} } + - id: send_mail + name: gmail-single-user-send-mail + description: "Send an email from the mailbox (optional single attachment as base64)." + inputs_schema: + properties: + to: { type: string, description: "Recipient(s), comma-separated" } + subject: { type: string, description: "Subject" } + body: { type: string, description: "Plain-text body" } + html_body: { type: string, description: "HTML body" } + cc: { type: string, description: "CC recipient(s), comma-separated" } + bcc: { type: string, description: "BCC recipient(s), comma-separated" } + reply_to: { type: string, description: "Reply-To address" } + attachment_name: { type: string, description: "Attachment file name (with attachment_base64)" } + attachment_base64: { type: string, description: "Attachment content, base64-encoded" } + required: [to, subject] + outputs_schema: { properties: {} } + - id: reply_mail + name: gmail-single-user-reply-mail + description: "Reply to a message (sets In-Reply-To/References so it threads correctly)." + inputs_schema: + properties: + to: { type: string, description: "Recipient(s), comma-separated" } + subject: { type: string, description: "Subject (keep the original for correct threading)" } + in_reply_to: { type: string, description: "Message-ID header of the message being replied to" } + references: { type: string, description: "References header (space-separated Message-IDs)" } + body: { type: string, description: "Plain-text body" } + html_body: { type: string, description: "HTML body" } + cc: { type: string, description: "CC recipient(s), comma-separated" } + bcc: { type: string, description: "BCC recipient(s), comma-separated" } + reply_to: { type: string, description: "Reply-To address" } + attachment_name: { type: string, description: "Attachment file name (with attachment_base64)" } + attachment_base64: { type: string, description: "Attachment content, base64-encoded" } + required: [to, subject, in_reply_to] + outputs_schema: { properties: {} } + - id: get_attachments + name: gmail-single-user-get-attachments + description: "Retrieve a message's attachments as base64." + risk: read + inputs_schema: + properties: + message_id: { type: string, description: "Message ID" } + required: [message_id] + outputs_schema: { properties: {} } + + - id: test_connection + name: gmail-single-user-test-connection + description: "Verify the OAuth refresh token by reading the mailbox profile (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } + +ingestion: + command: get_incidents + mapper: get_incidents + default_incident_type: "Gmail Single User Message" diff --git a/integrations/gmail-single-user/mappers/get_incidents.yaml b/integrations/gmail-single-user/mappers/get_incidents.yaml new file mode 100644 index 0000000..4683c4f --- /dev/null +++ b/integrations/gmail-single-user/mappers/get_incidents.yaml @@ -0,0 +1,15 @@ +name: "Gmail Single User Messages → OCSF" +description: "Maps a Gmail message (get_incidents, results_path = result) to OCSF finding fields. Messages are flattened by the fetch script (subject/from/to/date extracted from headers)." +field_mappings: + title: "subject" + severity: "2" + description: "snippet" +ocsf: + - { source_path: "id", ocsf_field: "finding_info.uid" } + - { source_path: "thread_id", ocsf_field: "finding_info.uid_alt" } + - { source_path: "subject", ocsf_field: "finding_info.title" } + - { source_path: "snippet", ocsf_field: "finding_info.desc" } + - { source_path: "date", ocsf_field: "finding_info.created_time" } + - { source_path: "labels", ocsf_field: "finding_info.types" } + - { source_path: "from", ocsf_field: "actor.user.name" } + - { source_path: "to", ocsf_field: "user.name" } diff --git a/integrations/gmail-single-user/scripts/auth_link.py b/integrations/gmail-single-user/scripts/auth_link.py new file mode 100644 index 0000000..b67b3e0 --- /dev/null +++ b/integrations/gmail-single-user/scripts/auth_link.py @@ -0,0 +1,43 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +SCOPES = ( + "https://www.googleapis.com/auth/gmail.readonly " + "https://www.googleapis.com/auth/gmail.compose " + "https://www.googleapis.com/auth/gmail.send" +) + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def main(): + cfg = _cfg() + client_id = str(cfg.get("client_id") or "") + if not client_id: + raise Exception("client_id is not set") + url_params = { + "client_id": client_id, + "redirect_uri": str(cfg.get("redirect_uri") or "http://localhost"), + "response_type": "code", + "scope": SCOPES, + "access_type": "offline", + "prompt": "consent", + } + if cfg.get("email"): + url_params["login_hint"] = cfg["email"] + link = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(url_params) + print(json.dumps({ + "auth_link": link, + "next": "Open the link, approve access, copy the 'code' from the redirect, then run gmail-single-user-exchange-code.", + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/gmail-single-user/scripts/exchange_code.py b/integrations/gmail-single-user/scripts/exchange_code.py new file mode 100644 index 0000000..5ce7ac3 --- /dev/null +++ b/integrations/gmail-single-user/scripts/exchange_code.py @@ -0,0 +1,45 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def main(): + cfg = _cfg() + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + code = str(inputs.get("code") or "") + if not code: + raise Exception("code is required") + if code.lower().startswith("code="): + code = urllib.parse.unquote(code[len("code="):]) + data = urllib.parse.urlencode({ + "code": code, + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "redirect_uri": str(inputs.get("redirect_uri") or cfg.get("redirect_uri") or "http://localhost"), + "grant_type": "authorization_code", + }).encode("utf-8") + req = urllib.request.Request(TOKEN_URL, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("refresh_token"): + raise Exception("No refresh_token in response (the code may be used/expired, or the client lacks offline access): " + json.dumps(tok)) + print(json.dumps({ + "refresh_token": tok["refresh_token"], + "note": "Paste this refresh_token into the instance configuration and save.", + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/gmail-single-user/scripts/get_attachments.py b/integrations/gmail-single-user/scripts/get_attachments.py new file mode 100644 index 0000000..0099fdb --- /dev/null +++ b/integrations/gmail-single-user/scripts/get_attachments.py @@ -0,0 +1,88 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + +TOKEN_URL = "https://oauth2.googleapis.com/token" +API = "https://gmail.googleapis.com/gmail/v1/users/me" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + refresh = str(cfg.get("refresh_token") or "") + if not refresh: + raise Exception("refresh_token is not set — run gmail-single-user-exchange-code and paste it into the config") + data = urllib.parse.urlencode({ + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "refresh_token": refresh, + "grant_type": "refresh_token", + }).encode("utf-8") + req = urllib.request.Request(TOKEN_URL, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token refresh failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + 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=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def collect_attachments(payload, out): + for part in payload.get("parts", []) or []: + collect_attachments(part, out) + if payload.get("filename") and payload.get("body", {}).get("attachmentId"): + out.append({ + "id": payload["body"]["attachmentId"], + "name": payload.get("filename", ""), + "mime_type": payload.get("mimeType", ""), + }) + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mid = str(inputs.get("message_id") or "") + if not mid: + raise Exception("message_id is required") + token = _token() + full = request("GET", "/messages/" + urllib.parse.quote(mid), params={"format": "full"}, token=token) + metas = [] + collect_attachments(full.get("payload", {}), metas) + attachments = [] + for meta in metas: + att = request("GET", "/messages/" + urllib.parse.quote(mid) + "/attachments/" + urllib.parse.quote(meta["id"]), token=token) + raw = att.get("data", "") + content = base64.urlsafe_b64decode((raw + "=" * (-len(raw) % 4)).encode("ascii")) if raw else b"" + attachments.append({ + "name": meta["name"], + "mime_type": meta["mime_type"], + "size": len(content), + "content_base64": base64.b64encode(content).decode("ascii"), + }) + print(json.dumps({"message_id": mid, "attachments": attachments})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/gmail-single-user/scripts/get_incidents.py b/integrations/gmail-single-user/scripts/get_incidents.py new file mode 100644 index 0000000..7c101fe --- /dev/null +++ b/integrations/gmail-single-user/scripts/get_incidents.py @@ -0,0 +1,144 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +from datetime import datetime, timezone + +TOKEN_URL = "https://oauth2.googleapis.com/token" +API = "https://gmail.googleapis.com/gmail/v1/users/me" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + refresh = str(cfg.get("refresh_token") or "") + if not refresh: + raise Exception("refresh_token is not set — run gmail-single-user-exchange-code and paste it into the config") + data = urllib.parse.urlencode({ + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "refresh_token": refresh, + "grant_type": "refresh_token", + }).encode("utf-8") + req = urllib.request.Request(TOKEN_URL, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token refresh failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + 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=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def flatten_message(msg): + payload = msg.get("payload", {}) + headers = {h.get("name", "").lower(): h.get("value", "") for h in payload.get("headers", [])} + acc = {"body_text": "", "body_html": "", "attachments": []} + + def walk(part): + for sub in part.get("parts", []) or []: + walk(sub) + mime = part.get("mimeType", "") + data = part.get("body", {}).get("data") + if part.get("filename"): + acc["attachments"].append({ + "id": part.get("body", {}).get("attachmentId", ""), + "name": part.get("filename", ""), + "mime_type": mime, + }) + elif data: + pad = data + "=" * (-len(data) % 4) + text = base64.urlsafe_b64decode(pad.encode("ascii")).decode("utf-8", "replace") + if mime == "text/html": + acc["body_html"] += text + else: + acc["body_text"] += text + + walk(payload) + return { + "id": msg.get("id"), + "thread_id": msg.get("threadId"), + "labels": msg.get("labelIds", []), + "snippet": msg.get("snippet", ""), + "internal_date": msg.get("internalDate"), + "subject": headers.get("subject", ""), + "from": headers.get("from", ""), + "to": headers.get("to", ""), + "cc": headers.get("cc", ""), + "date": headers.get("date", ""), + "message_id_header": headers.get("message-id", ""), + "body_text": acc["body_text"], + "body_html": acc["body_html"], + "attachments": acc["attachments"], + } + + +def to_epoch(v): + s = str(v or "").strip() + if not s: + return None + if s.isdigit(): + n = int(s) + return n // 1000 if n > 10**12 else n + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc) + return int(dt.timestamp()) + except Exception: + return None + + +def build_query(inputs): + parts = [] + for key, field in [("subject", "subject"), ("from", "from"), ("to", "to"), ("filename", "filename")]: + if inputs.get(key): + parts.append(field + ":" + str(inputs[key])) + if inputs.get("has_attachments"): + parts.append("has:attachment") + if inputs.get("query"): + parts.append(str(inputs["query"])) + epoch = to_epoch(inputs.get("after")) + if epoch is not None: + parts.append("after:" + str(epoch)) + return " ".join(parts).strip() + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + token = _token() + limit = int(inputs.get("max") or 50) + params = {"q": build_query(inputs), "maxResults": limit} + if inputs.get("label_id"): + params["labelIds"] = str(inputs["label_id"]) + listing = request("GET", "/messages", params=params, token=token) + result = [] + for m in listing.get("messages", []): + full = request("GET", "/messages/" + urllib.parse.quote(m["id"]), params={"format": "full"}, token=token) + result.append(flatten_message(full)) + print(json.dumps({"result": result})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/gmail-single-user/scripts/get_mail.py b/integrations/gmail-single-user/scripts/get_mail.py new file mode 100644 index 0000000..cb1afa3 --- /dev/null +++ b/integrations/gmail-single-user/scripts/get_mail.py @@ -0,0 +1,106 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + +TOKEN_URL = "https://oauth2.googleapis.com/token" +API = "https://gmail.googleapis.com/gmail/v1/users/me" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + refresh = str(cfg.get("refresh_token") or "") + if not refresh: + raise Exception("refresh_token is not set — run gmail-single-user-exchange-code and paste it into the config") + data = urllib.parse.urlencode({ + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "refresh_token": refresh, + "grant_type": "refresh_token", + }).encode("utf-8") + req = urllib.request.Request(TOKEN_URL, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token refresh failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + _token()} + 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=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def flatten_message(msg): + payload = msg.get("payload", {}) + headers = {h.get("name", "").lower(): h.get("value", "") for h in payload.get("headers", [])} + acc = {"body_text": "", "body_html": "", "attachments": []} + + def walk(part): + for sub in part.get("parts", []) or []: + walk(sub) + mime = part.get("mimeType", "") + data = part.get("body", {}).get("data") + if part.get("filename"): + acc["attachments"].append({ + "id": part.get("body", {}).get("attachmentId", ""), + "name": part.get("filename", ""), + "mime_type": mime, + }) + elif data: + pad = data + "=" * (-len(data) % 4) + text = base64.urlsafe_b64decode(pad.encode("ascii")).decode("utf-8", "replace") + if mime == "text/html": + acc["body_html"] += text + else: + acc["body_text"] += text + + walk(payload) + return { + "id": msg.get("id"), + "thread_id": msg.get("threadId"), + "labels": msg.get("labelIds", []), + "snippet": msg.get("snippet", ""), + "internal_date": msg.get("internalDate"), + "subject": headers.get("subject", ""), + "from": headers.get("from", ""), + "to": headers.get("to", ""), + "cc": headers.get("cc", ""), + "date": headers.get("date", ""), + "message_id_header": headers.get("message-id", ""), + "body_text": acc["body_text"], + "body_html": acc["body_html"], + "attachments": acc["attachments"], + } + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mid = str(inputs.get("message_id") or "") + if not mid: + raise Exception("message_id is required") + full = request("GET", "/messages/" + urllib.parse.quote(mid), params={"format": "full"}) + print(json.dumps(flatten_message(full))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/gmail-single-user/scripts/reply_mail.py b/integrations/gmail-single-user/scripts/reply_mail.py new file mode 100644 index 0000000..e2a7baa --- /dev/null +++ b/integrations/gmail-single-user/scripts/reply_mail.py @@ -0,0 +1,115 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +TOKEN_URL = "https://oauth2.googleapis.com/token" +API = "https://gmail.googleapis.com/gmail/v1/users/me" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + refresh = str(cfg.get("refresh_token") or "") + if not refresh: + raise Exception("refresh_token is not set — run gmail-single-user-exchange-code and paste it into the config") + data = urllib.parse.urlencode({ + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "refresh_token": refresh, + "grant_type": "refresh_token", + }).encode("utf-8") + req = urllib.request.Request(TOKEN_URL, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token refresh failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + _token()} + 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=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def build_raw(cfg, inputs, in_reply_to=None, references=None): + body = inputs.get("body") + html = inputs.get("html_body") + att_name = inputs.get("attachment_name") + att_b64 = inputs.get("attachment_base64") + has_att = bool(att_name and att_b64) + + def body_part(): + if body and html: + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(body, "plain", "utf-8")) + alt.attach(MIMEText(html, "html", "utf-8")) + return alt + if html: + return MIMEText(html, "html", "utf-8") + return MIMEText(body or "", "plain", "utf-8") + + if has_att: + msg = MIMEMultipart("mixed") + msg.attach(body_part()) + part = MIMEBase("application", "octet-stream") + part.set_payload(base64.b64decode(att_b64)) + encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment", filename=str(att_name)) + msg.attach(part) + else: + msg = body_part() + + msg["to"] = str(inputs.get("to") or "") + if inputs.get("cc"): + msg["cc"] = str(inputs["cc"]) + if inputs.get("bcc"): + msg["bcc"] = str(inputs["bcc"]) + msg["subject"] = str(inputs.get("subject") or "") + msg["from"] = str(cfg.get("send_as") or cfg.get("email") or "") + if inputs.get("reply_to"): + msg["reply-to"] = str(inputs["reply_to"]) + if in_reply_to: + msg["In-Reply-To"] = str(in_reply_to) + if references: + msg["References"] = str(references) + return base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii") + + +def main(): + cfg = _cfg() + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + if not str(inputs.get("to") or ""): + raise Exception("to is required") + in_reply_to = str(inputs.get("in_reply_to") or "") + if not in_reply_to: + raise Exception("in_reply_to is required") + references = str(inputs.get("references") or "") or in_reply_to + raw = build_raw(cfg, inputs, in_reply_to=in_reply_to, references=references) + print(json.dumps(request("POST", "/messages/send", body={"raw": raw}))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/gmail-single-user/scripts/search.py b/integrations/gmail-single-user/scripts/search.py new file mode 100644 index 0000000..563f854 --- /dev/null +++ b/integrations/gmail-single-user/scripts/search.py @@ -0,0 +1,128 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + +TOKEN_URL = "https://oauth2.googleapis.com/token" +API = "https://gmail.googleapis.com/gmail/v1/users/me" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + refresh = str(cfg.get("refresh_token") or "") + if not refresh: + raise Exception("refresh_token is not set — run gmail-single-user-exchange-code and paste it into the config") + data = urllib.parse.urlencode({ + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "refresh_token": refresh, + "grant_type": "refresh_token", + }).encode("utf-8") + req = urllib.request.Request(TOKEN_URL, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token refresh failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None, token=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + (token or _token())} + 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=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def flatten_message(msg): + payload = msg.get("payload", {}) + headers = {h.get("name", "").lower(): h.get("value", "") for h in payload.get("headers", [])} + acc = {"body_text": "", "body_html": "", "attachments": []} + + def walk(part): + for sub in part.get("parts", []) or []: + walk(sub) + mime = part.get("mimeType", "") + data = part.get("body", {}).get("data") + if part.get("filename"): + acc["attachments"].append({ + "id": part.get("body", {}).get("attachmentId", ""), + "name": part.get("filename", ""), + "mime_type": mime, + }) + elif data: + pad = data + "=" * (-len(data) % 4) + text = base64.urlsafe_b64decode(pad.encode("ascii")).decode("utf-8", "replace") + if mime == "text/html": + acc["body_html"] += text + else: + acc["body_text"] += text + + walk(payload) + return { + "id": msg.get("id"), + "thread_id": msg.get("threadId"), + "labels": msg.get("labelIds", []), + "snippet": msg.get("snippet", ""), + "internal_date": msg.get("internalDate"), + "subject": headers.get("subject", ""), + "from": headers.get("from", ""), + "to": headers.get("to", ""), + "cc": headers.get("cc", ""), + "date": headers.get("date", ""), + "message_id_header": headers.get("message-id", ""), + "body_text": acc["body_text"], + "body_html": acc["body_html"], + "attachments": acc["attachments"], + } + + +def build_query(inputs): + parts = [] + for key, field in [("subject", "subject"), ("from", "from"), ("to", "to"), ("filename", "filename")]: + if inputs.get(key): + parts.append(field + ":" + str(inputs[key])) + if inputs.get("has_attachments"): + parts.append("has:attachment") + if inputs.get("query"): + parts.append(str(inputs["query"])) + return " ".join(parts).strip() + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + token = _token() + max_results = min(int(inputs.get("max_results") or 100), 500) + params = {"q": build_query(inputs), "maxResults": max_results} + if inputs.get("page_token"): + params["pageToken"] = str(inputs["page_token"]) + if inputs.get("label_id"): + params["labelIds"] = str(inputs["label_id"]) + if inputs.get("include_spam_trash"): + params["includeSpamTrash"] = "true" + listing = request("GET", "/messages", params=params, token=token) + result = [] + for m in listing.get("messages", []): + full = request("GET", "/messages/" + urllib.parse.quote(m["id"]), params={"format": "full"}, token=token) + result.append(flatten_message(full)) + print(json.dumps({"result": result, "next_page_token": listing.get("nextPageToken", "")})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/gmail-single-user/scripts/send_mail.py b/integrations/gmail-single-user/scripts/send_mail.py new file mode 100644 index 0000000..a127247 --- /dev/null +++ b/integrations/gmail-single-user/scripts/send_mail.py @@ -0,0 +1,111 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +TOKEN_URL = "https://oauth2.googleapis.com/token" +API = "https://gmail.googleapis.com/gmail/v1/users/me" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + refresh = str(cfg.get("refresh_token") or "") + if not refresh: + raise Exception("refresh_token is not set — run gmail-single-user-exchange-code and paste it into the config") + data = urllib.parse.urlencode({ + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "refresh_token": refresh, + "grant_type": "refresh_token", + }).encode("utf-8") + req = urllib.request.Request(TOKEN_URL, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token refresh failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + _token()} + 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=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def build_raw(cfg, inputs, in_reply_to=None, references=None): + body = inputs.get("body") + html = inputs.get("html_body") + att_name = inputs.get("attachment_name") + att_b64 = inputs.get("attachment_base64") + has_att = bool(att_name and att_b64) + + def body_part(): + if body and html: + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(body, "plain", "utf-8")) + alt.attach(MIMEText(html, "html", "utf-8")) + return alt + if html: + return MIMEText(html, "html", "utf-8") + return MIMEText(body or "", "plain", "utf-8") + + if has_att: + msg = MIMEMultipart("mixed") + msg.attach(body_part()) + part = MIMEBase("application", "octet-stream") + part.set_payload(base64.b64decode(att_b64)) + encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment", filename=str(att_name)) + msg.attach(part) + else: + msg = body_part() + + msg["to"] = str(inputs.get("to") or "") + if inputs.get("cc"): + msg["cc"] = str(inputs["cc"]) + if inputs.get("bcc"): + msg["bcc"] = str(inputs["bcc"]) + msg["subject"] = str(inputs.get("subject") or "") + msg["from"] = str(cfg.get("send_as") or cfg.get("email") or "") + if inputs.get("reply_to"): + msg["reply-to"] = str(inputs["reply_to"]) + if in_reply_to: + msg["In-Reply-To"] = str(in_reply_to) + if references: + msg["References"] = str(references) + return base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii") + + +def main(): + cfg = _cfg() + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + if not str(inputs.get("to") or ""): + raise Exception("to is required") + raw = build_raw(cfg, inputs) + print(json.dumps(request("POST", "/messages/send", body={"raw": raw}))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/gmail-single-user/scripts/test_connection.py b/integrations/gmail-single-user/scripts/test_connection.py new file mode 100644 index 0000000..4590b27 --- /dev/null +++ b/integrations/gmail-single-user/scripts/test_connection.py @@ -0,0 +1,61 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + +TOKEN_URL = "https://oauth2.googleapis.com/token" +API = "https://gmail.googleapis.com/gmail/v1/users/me" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(): + cfg = _cfg() + refresh = str(cfg.get("refresh_token") or "") + if not refresh: + raise Exception("refresh_token is not set — run gmail-single-user-exchange-code and paste it into the config") + data = urllib.parse.urlencode({ + "client_id": str(cfg.get("client_id") or ""), + "client_secret": str(cfg.get("client_secret") or ""), + "refresh_token": refresh, + "grant_type": "refresh_token", + }).encode("utf-8") + req = urllib.request.Request(TOKEN_URL, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token refresh failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, path, params=None, body=None): + url = API + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": "Bearer " + _token()} + 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=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def main(): + res = request("GET", "/profile") + if not res.get("emailAddress"): + raise Exception("Unexpected response: " + json.dumps(res)) + print(json.dumps({"ok": True, "email": res.get("emailAddress"), "messages_total": res.get("messagesTotal")})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)