diff --git a/integrations/gmail/incident-types/gmail-message.yaml b/integrations/gmail/incident-types/gmail-message.yaml new file mode 100644 index 0000000..f74bdf3 --- /dev/null +++ b/integrations/gmail/incident-types/gmail-message.yaml @@ -0,0 +1,3 @@ +name: "Gmail Message" +color: "#ea4335" +icon: "alert" diff --git a/integrations/gmail/manifest.yaml b/integrations/gmail/manifest.yaml new file mode 100644 index 0000000..923790a --- /dev/null +++ b/integrations/gmail/manifest.yaml @@ -0,0 +1,339 @@ +id: gmail +name: Gmail +version: 1.0.0 +description: "Gmail API + Directory API — mailbox search/read/send/trash/labels, attachments retrieval, vacation auto-reply, filters, forwarding addresses, delegates, and Workspace user administration; message ingestion (get_incidents) with an OCSF mapper; service-account auth with domain-wide delegation. Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)." +changelog: "1.0.0 — Initial release: mailbox commands (search, get mail/thread, send with attachments, trash/permanent delete, label moves, attachments download, labels list), vacation auto-reply get/set, mail filters (add/list/remove), forwarding addresses and auto-forwarding, delegates, Workspace user administration (list/get/create/delete, password reset, directory visibility), and message ingestion with a bundled OCSF mapper." +category: email + +# Authentication: Google service account with domain-wide delegation. +# Every command builds a JWT (RS256, signed locally with the service account +# private key from service_account_json), sets `sub` to the mailbox being +# impersonated, exchanges it at https://oauth2.googleapis.com/token +# (grant_type=jwt-bearer) for an access token, then calls the Gmail / +# Admin SDK Directory REST APIs with that bearer token. +# The service account's client ID must be granted the Gmail scopes (and the +# Directory scopes for user-administration commands) in the Google Workspace +# Admin console: Security → API controls → Domain-wide delegation. +config_schema: + properties: + service_account_json: + type: string + description: "Google service account key file content (full JSON) — the account must have domain-wide delegation enabled" + x-soar-sensitive: true + user_id: + type: string + description: "Default mailbox to impersonate — also the admin account for user-administration commands" + required: + - service_account_json + - user_id + +commands: + # ── Ingestion ───────────────────────────────────────────────────────────── + - id: get_incidents + name: gmail-get-incidents + description: "Fetch messages from a mailbox for ingestion (Gmail search query + time watermark). Returns {result:[flattened messages]}; use result as the alert rule results path." + risk: read + inputs_schema: + properties: + query: { type: string, description: "Gmail search query (e.g. is:unread in:inbox has:attachment)" } + after: { type: string, description: "Lower time bound — epoch seconds, epoch ms or ISO8601 (incremental fetch watermark, appended as after:)" } + max: { type: number, description: "Maximum messages to fetch (default 50)" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [] + outputs_schema: { properties: {} } + ingest: + results_path: result + dedup_key: id + incremental_field: after + + # ── Mailbox ─────────────────────────────────────────────────────────────── + - id: search + name: gmail-search + description: "Search messages with a Gmail query and return them flattened (headers, bodies, attachment metadata) plus a next_page_token for pagination." + risk: read + inputs_schema: + properties: + query: { type: string, description: "Gmail search query (e.g. from:alice@example.com subject:invoice newer_than:7d)" } + max_results: { type: number, description: "Maximum messages to return (default 100, capped at 500)" } + page_token: { type: string, description: "Page token from a previous call (next_page_token)" } + label_id: { type: string, description: "Restrict to a single label ID (e.g. INBOX, SPAM or a user label ID from gmail-list-labels)" } + include_spam_trash: { type: boolean, description: "Include messages from SPAM and TRASH" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [] + outputs_schema: { properties: {} } + - id: get_mail + name: gmail-get-mail + description: "Retrieve a single message by ID, flattened (subject/from/to/cc, text and HTML bodies, attachment metadata)." + risk: read + inputs_schema: + properties: + message_id: { type: string, description: "Gmail message ID" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [message_id] + outputs_schema: { properties: {} } + - id: get_thread + name: gmail-get-thread + description: "Retrieve a conversation thread by ID with every message flattened." + risk: read + inputs_schema: + properties: + thread_id: { type: string, description: "Gmail thread ID" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [thread_id] + outputs_schema: { properties: {} } + - id: send_mail + name: gmail-send-mail + description: "Send an email from the impersonated mailbox — plain text and/or HTML body, optional single attachment (base64), reply threading via In-Reply-To/References." + inputs_schema: + properties: + to: { type: string, description: "Recipients, comma-separated" } + cc: { type: string, description: "Cc recipients, comma-separated" } + bcc: { type: string, description: "Bcc recipients, comma-separated" } + subject: { type: string, description: "Message subject" } + body: { type: string, description: "Plain-text body" } + html_body: { type: string, description: "HTML body (sent alongside the plain-text part when both are given)" } + reply_to: { type: string, description: "Reply-To header" } + in_reply_to: { type: string, description: "In-Reply-To header (Message-ID of the message being answered — see message_id_header)" } + references: { type: string, description: "References header (thread Message-ID chain)" } + attachment_name: { type: string, description: "Attachment file name (with attachment_base64)" } + attachment_base64: { type: string, description: "Attachment content, base64-encoded" } + user_id: { type: string, description: "Sender mailbox to impersonate (default from instance config)" } + required: [to, subject] + outputs_schema: { properties: {} } + - id: delete_mail + name: gmail-delete-mail + description: "Move a message to Trash, or delete it permanently (bypasses Trash) when permanent is true." + inputs_schema: + properties: + message_id: { type: string, description: "Gmail message ID" } + permanent: { type: boolean, description: "Permanently delete instead of moving to Trash (irreversible)" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [message_id] + outputs_schema: { properties: {} } + - id: move_mail + name: gmail-move-mail + description: "Add and/or remove labels on a message (move between folders — e.g. remove INBOX, add SPAM or a quarantine label)." + inputs_schema: + properties: + message_id: { type: string, description: "Gmail message ID" } + add_labels: { type: string, description: "Label IDs to add, comma-separated" } + remove_labels: { type: string, description: "Label IDs to remove, comma-separated" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [message_id] + outputs_schema: { properties: {} } + - id: get_attachments + name: gmail-get-attachments + description: "Download every attachment of a message; returns name, MIME type, size and base64 content for each." + risk: read + inputs_schema: + properties: + message_id: { type: string, description: "Gmail message ID" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [message_id] + outputs_schema: { properties: {} } + - id: list_labels + name: gmail-list-labels + description: "List the mailbox's labels (system and user labels with their IDs)." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [] + outputs_schema: { properties: {} } + + # ── Auto-reply (vacation responder) ─────────────────────────────────────── + - id: autoreply_get + name: gmail-autoreply-get + description: "Get the mailbox's vacation auto-reply settings." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [] + outputs_schema: { properties: {} } + - id: autoreply_set + name: gmail-autoreply-set + description: "Enable, update or disable the mailbox's vacation auto-reply." + inputs_schema: + properties: + enable: { type: boolean, description: "Enable the auto-reply (default true; false disables it)" } + response_subject: { type: string, description: "Auto-reply subject" } + response_body: { type: string, description: "Auto-reply plain-text body" } + response_body_html: { type: string, description: "Auto-reply HTML body" } + contacts_only: { type: boolean, description: "Only reply to senders in the user's contacts" } + domain_only: { type: boolean, description: "Only reply to senders in the same domain" } + start_time: { type: string, description: "Start time — epoch ms or ISO8601" } + end_time: { type: string, description: "End time — epoch ms or ISO8601" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [] + outputs_schema: { properties: {} } + + # ── Filters ─────────────────────────────────────────────────────────────── + - id: add_filter + name: gmail-add-filter + description: "Create a mail filter on the mailbox (criteria: from/to/subject/query/has_attachment; actions: add/remove labels, forward)." + inputs_schema: + properties: + from: { type: string, description: "Criteria — sender address" } + to: { type: string, description: "Criteria — recipient address" } + subject: { type: string, description: "Criteria — subject contains" } + query: { type: string, description: "Criteria — Gmail search query" } + has_attachment: { type: boolean, description: "Criteria — only messages with attachments" } + add_labels: { type: string, description: "Action — label IDs to add, comma-separated" } + remove_labels: { type: string, description: "Action — label IDs to remove, comma-separated (e.g. INBOX to archive)" } + forward: { type: string, description: "Action — forward to this address (must be a verified forwarding address)" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [] + outputs_schema: { properties: {} } + - id: list_filters + name: gmail-list-filters + description: "List the mailbox's mail filters." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [] + outputs_schema: { properties: {} } + - id: remove_filter + name: gmail-remove-filter + description: "Delete a mail filter by ID." + inputs_schema: + properties: + filter_id: { type: string, description: "Filter ID (from gmail-list-filters)" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [filter_id] + outputs_schema: { properties: {} } + + # ── Forwarding ──────────────────────────────────────────────────────────── + - id: forwarding_add + name: gmail-forwarding-add + description: "Register a forwarding address on the mailbox (Google may require verification before it becomes usable)." + inputs_schema: + properties: + forwarding_email: { type: string, description: "Forwarding address to register" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [forwarding_email] + outputs_schema: { properties: {} } + - id: forwarding_list + name: gmail-forwarding-list + description: "List the mailbox's registered forwarding addresses and their verification status." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [] + outputs_schema: { properties: {} } + - id: forwarding_remove + name: gmail-forwarding-remove + description: "Delete a forwarding address from the mailbox." + inputs_schema: + properties: + forwarding_email: { type: string, description: "Forwarding address to remove" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [forwarding_email] + outputs_schema: { properties: {} } + - id: forwarding_update + name: gmail-forwarding-update + description: "Enable auto-forwarding of the mailbox to a verified forwarding address, with a disposition for the forwarded copy." + inputs_schema: + properties: + forwarding_email: { type: string, description: "Verified forwarding address to auto-forward to" } + disposition: { type: string, description: "What happens to the forwarded message in the mailbox: leaveInInbox, archive, trash or markRead" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [forwarding_email] + outputs_schema: { properties: {} } + + # ── Delegates ───────────────────────────────────────────────────────────── + - id: delegate_add + name: gmail-delegate-add + description: "Grant a delegate access to the mailbox (delegate can read, send and delete on the owner's behalf)." + inputs_schema: + properties: + delegate_email: { type: string, description: "Delegate's email address (same Workspace domain)" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [delegate_email] + outputs_schema: { properties: {} } + - id: delegate_remove + name: gmail-delegate-remove + description: "Revoke a delegate's access to the mailbox." + inputs_schema: + properties: + delegate_email: { type: string, description: "Delegate's email address to revoke" } + user_id: { type: string, description: "Mailbox to impersonate (default from instance config)" } + required: [delegate_email] + outputs_schema: { properties: {} } + + # ── Workspace user administration (Directory API — runs as the admin) ──── + - id: list_users + name: gmail-list-users + description: "List Workspace users in a domain (Directory API, impersonates the configured admin account)." + risk: read + inputs_schema: + properties: + domain: { type: string, description: "Domain to list (defaults to the admin account's domain)" } + query: { type: string, description: "Directory search query (e.g. email:john* or name:'Jane Doe')" } + max_results: { type: number, description: "Maximum users per page (default 100, max 500)" } + page_token: { type: string, description: "Page token from a previous call" } + show_deleted: { type: boolean, description: "List recently deleted users instead of active ones" } + required: [] + outputs_schema: { properties: {} } + - id: get_user + name: gmail-get-user + description: "Get a Workspace user by primary email, alias or unique ID (Directory API)." + risk: read + inputs_schema: + properties: + user_key: { type: string, description: "User's primary email, alias email or unique ID" } + required: [user_key] + outputs_schema: { properties: {} } + - id: create_user + name: gmail-create-user + description: "Create a Workspace user (Directory API)." + inputs_schema: + properties: + email: { type: string, description: "New user's primary email address" } + first_name: { type: string, description: "Given name" } + last_name: { type: string, description: "Family name" } + password: { type: string, description: "Initial password" } + required: [email, first_name, last_name, password] + outputs_schema: { properties: {} } + - id: delete_user + name: gmail-delete-user + description: "Delete a Workspace user (Directory API)." + inputs_schema: + properties: + user_key: { type: string, description: "User's primary email, alias email or unique ID" } + required: [user_key] + outputs_schema: { properties: {} } + - id: set_password + name: gmail-set-password + description: "Reset a Workspace user's password (Directory API) — a common containment step for a compromised account." + inputs_schema: + properties: + user_key: { type: string, description: "User's primary email, alias email or unique ID" } + password: { type: string, description: "New password" } + required: [user_key, password] + outputs_schema: { properties: {} } + - id: hide_user + name: gmail-hide-user + description: "Set a Workspace user's global directory visibility (Directory API)." + inputs_schema: + properties: + user_key: { type: string, description: "User's primary email, alias email or unique ID" } + visible: { type: boolean, description: "true to show the user in the global directory, false to hide" } + required: [user_key] + outputs_schema: { properties: {} } + + - id: test_connection + name: gmail-test-connection + description: "Verify the service account credentials and delegation by fetching the configured mailbox's 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 Message" diff --git a/integrations/gmail/mappers/get_incidents.yaml b/integrations/gmail/mappers/get_incidents.yaml new file mode 100644 index 0000000..ac1dd8b --- /dev/null +++ b/integrations/gmail/mappers/get_incidents.yaml @@ -0,0 +1,15 @@ +name: "Gmail 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/scripts/add_filter.py b/integrations/gmail/scripts/add_filter.py new file mode 100644 index 0000000..862354e --- /dev/null +++ b/integrations/gmail/scripts/add_filter.py @@ -0,0 +1,102 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.basic"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + criteria = {} + if inputs.get("from"): + criteria["from"] = inputs.get("from") + if inputs.get("to"): + criteria["to"] = inputs.get("to") + if inputs.get("subject"): + criteria["subject"] = inputs.get("subject") + if inputs.get("query"): + criteria["query"] = inputs.get("query") + if inputs.get("has_attachment"): + criteria["hasAttachment"] = True + if not criteria: + raise Exception("no criteria given") + + action = {} + add_labels = [s.strip() for s in str(inputs.get("add_labels") or "").split(",") if s.strip()] + if add_labels: + action["addLabelIds"] = add_labels + remove_labels = [s.strip() for s in str(inputs.get("remove_labels") or "").split(",") if s.strip()] + if remove_labels: + action["removeLabelIds"] = remove_labels + if inputs.get("forward"): + action["forward"] = inputs.get("forward") + + result = request("POST", base + "/settings/filters", SCOPES, + body={"criteria": criteria, "action": action}, subject=mailbox) + print(json.dumps(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/scripts/autoreply_get.py b/integrations/gmail/scripts/autoreply_get.py new file mode 100644 index 0000000..7c5ac87 --- /dev/null +++ b/integrations/gmail/scripts/autoreply_get.py @@ -0,0 +1,77 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.basic"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + res = request("GET", base + "/settings/vacation", SCOPES, subject=mailbox) + print(json.dumps(res)) + + +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/scripts/autoreply_set.py b/integrations/gmail/scripts/autoreply_set.py new file mode 100644 index 0000000..bff051f --- /dev/null +++ b/integrations/gmail/scripts/autoreply_set.py @@ -0,0 +1,112 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt +from datetime import datetime + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.basic"] + + +def _bool(value, default=False): + if value in (None, ""): + return default + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "y") + return bool(value) + + +def _to_epoch_ms(value): + s = str(value).strip() + if s.isdigit(): + n = int(s) + if n < 10**12: + n *= 1000 + return n + return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000) + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + body = {"enableAutoReply": _bool(inputs.get("enable"), default=True)} + if inputs.get("response_subject"): + body["responseSubject"] = str(inputs["response_subject"]) + if inputs.get("response_body"): + body["responseBodyPlainText"] = str(inputs["response_body"]) + if inputs.get("response_body_html"): + body["responseBodyHtml"] = str(inputs["response_body_html"]) + if inputs.get("contacts_only") not in (None, ""): + body["restrictToContacts"] = _bool(inputs.get("contacts_only")) + if inputs.get("domain_only") not in (None, ""): + body["restrictToDomain"] = _bool(inputs.get("domain_only")) + if inputs.get("start_time") not in (None, ""): + body["startTime"] = _to_epoch_ms(inputs["start_time"]) + if inputs.get("end_time") not in (None, ""): + body["endTime"] = _to_epoch_ms(inputs["end_time"]) + + res = request("PUT", base + "/settings/vacation", SCOPES, body=body, subject=mailbox) + print(json.dumps(res)) + + +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/scripts/create_user.py b/integrations/gmail/scripts/create_user.py new file mode 100644 index 0000000..d683ead --- /dev/null +++ b/integrations/gmail/scripts/create_user.py @@ -0,0 +1,97 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/admin.directory.user"] +ADMIN_BASE = "https://admin.googleapis.com/admin/directory/v1" + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + email = str(inputs.get("email") or "").strip() + first_name = str(inputs.get("first_name") or "").strip() + last_name = str(inputs.get("last_name") or "").strip() + password = str(inputs.get("password") or "") + if not email: + raise Exception("email is required") + if not first_name: + raise Exception("first_name is required") + if not last_name: + raise Exception("last_name is required") + if len(password) < 8 or len(password) > 100: + raise Exception("password must be between 8 and 100 characters") + + body = { + "primaryEmail": email, + "name": { + "givenName": first_name, + "familyName": last_name, + "fullName": first_name + " " + last_name, + }, + "password": password, + } + result = request("POST", ADMIN_BASE + "/users", SCOPES, body=body) + print(json.dumps(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/scripts/delegate_add.py b/integrations/gmail/scripts/delegate_add.py new file mode 100644 index 0000000..47e9f63 --- /dev/null +++ b/integrations/gmail/scripts/delegate_add.py @@ -0,0 +1,83 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.sharing"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + delegate_email = str(inputs.get("delegate_email") or "").strip() + if not delegate_email: + raise Exception("delegate_email is required") + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + result = request("POST", base + "/settings/delegates", SCOPES, + body={"delegateEmail": delegate_email}, subject=mailbox) + if not result: + result = {"ok": True, "delegate_email": delegate_email} + print(json.dumps(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/scripts/delegate_remove.py b/integrations/gmail/scripts/delegate_remove.py new file mode 100644 index 0000000..031f3a6 --- /dev/null +++ b/integrations/gmail/scripts/delegate_remove.py @@ -0,0 +1,84 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.sharing"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + delegate_email = str(inputs.get("delegate_email") or "").strip() + if not delegate_email: + raise Exception("delegate_email is required") + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + result = request("DELETE", + base + "/settings/delegates/" + urllib.parse.quote(delegate_email, safe=""), + SCOPES, subject=mailbox) + if not result: + result = {"ok": True, "delegate_email": delegate_email} + print(json.dumps(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/scripts/delete_mail.py b/integrations/gmail/scripts/delete_mail.py new file mode 100644 index 0000000..74f903f --- /dev/null +++ b/integrations/gmail/scripts/delete_mail.py @@ -0,0 +1,93 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://mail.google.com/"] + + +def _bool(value): + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "y") + return bool(value) + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + message_id = str(inputs.get("message_id") or "").strip() + if not message_id: + raise Exception("message_id is required") + + if _bool(inputs.get("permanent")): + request("DELETE", base + "/messages/" + urllib.parse.quote(message_id, safe=""), + SCOPES, subject=mailbox) + print(json.dumps({"ok": True, "message_id": message_id})) + else: + res = request("POST", base + "/messages/" + urllib.parse.quote(message_id, safe="") + "/trash", + SCOPES, body={}, subject=mailbox) + print(json.dumps(res)) + + +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/scripts/delete_user.py b/integrations/gmail/scripts/delete_user.py new file mode 100644 index 0000000..62e4503 --- /dev/null +++ b/integrations/gmail/scripts/delete_user.py @@ -0,0 +1,81 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/admin.directory.user"] +ADMIN_BASE = "https://admin.googleapis.com/admin/directory/v1" + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + user_key = str(inputs.get("user_key") or "").strip() + if not user_key: + raise Exception("user_key is required") + + result = request("DELETE", ADMIN_BASE + "/users/" + urllib.parse.quote(user_key, safe=""), SCOPES) + if not result: + result = {"ok": True, "user_key": user_key} + print(json.dumps(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/scripts/forwarding_add.py b/integrations/gmail/scripts/forwarding_add.py new file mode 100644 index 0000000..02a93d0 --- /dev/null +++ b/integrations/gmail/scripts/forwarding_add.py @@ -0,0 +1,81 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.sharing"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + forwarding_email = str(inputs.get("forwarding_email") or "").strip() + if not forwarding_email: + raise Exception("forwarding_email is required") + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + result = request("POST", base + "/settings/forwardingAddresses", SCOPES, + body={"forwardingEmail": forwarding_email}, subject=mailbox) + print(json.dumps(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/scripts/forwarding_list.py b/integrations/gmail/scripts/forwarding_list.py new file mode 100644 index 0000000..91a6de2 --- /dev/null +++ b/integrations/gmail/scripts/forwarding_list.py @@ -0,0 +1,77 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.basic"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + result = request("GET", base + "/settings/forwardingAddresses", SCOPES, subject=mailbox) + print(json.dumps(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/scripts/forwarding_remove.py b/integrations/gmail/scripts/forwarding_remove.py new file mode 100644 index 0000000..4abe424 --- /dev/null +++ b/integrations/gmail/scripts/forwarding_remove.py @@ -0,0 +1,84 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.sharing"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + forwarding_email = str(inputs.get("forwarding_email") or "").strip() + if not forwarding_email: + raise Exception("forwarding_email is required") + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + result = request("DELETE", + base + "/settings/forwardingAddresses/" + urllib.parse.quote(forwarding_email, safe=""), + SCOPES, subject=mailbox) + if not result: + result = {"ok": True, "forwarding_email": forwarding_email} + print(json.dumps(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/scripts/forwarding_update.py b/integrations/gmail/scripts/forwarding_update.py new file mode 100644 index 0000000..740f2e0 --- /dev/null +++ b/integrations/gmail/scripts/forwarding_update.py @@ -0,0 +1,83 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.sharing"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + forwarding_email = str(inputs.get("forwarding_email") or "").strip() + if not forwarding_email: + raise Exception("forwarding_email is required") + disposition = str(inputs.get("disposition") or "leaveInInbox") + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + result = request("PUT", base + "/settings/autoForwarding", SCOPES, + body={"emailAddress": forwarding_email, "enabled": True, "disposition": disposition}, + subject=mailbox) + print(json.dumps(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/scripts/get_attachments.py b/integrations/gmail/scripts/get_attachments.py new file mode 100644 index 0000000..3c0c1ec --- /dev/null +++ b/integrations/gmail/scripts/get_attachments.py @@ -0,0 +1,144 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt +import base64 + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] + + +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: + text = base64.urlsafe_b64decode(data.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", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + message_id = str(inputs.get("message_id") or "").strip() + if not message_id: + raise Exception("message_id is required") + + msg = request("GET", base + "/messages/" + urllib.parse.quote(message_id, safe=""), + SCOPES, params={"format": "full"}, subject=mailbox) + flat = flatten_message(msg) + + attachments = [] + for att in flat["attachments"]: + att_id = att.get("id", "") + if not att_id: + continue + res = request("GET", + base + "/messages/" + urllib.parse.quote(message_id, safe="") + + "/attachments/" + urllib.parse.quote(att_id, safe=""), + SCOPES, subject=mailbox) + data = str(res.get("data") or "") + raw = base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)) if data else b"" + attachments.append({ + "name": att.get("name", ""), + "mime_type": att.get("mime_type", ""), + "size": res.get("size", len(raw)), + "content_base64": base64.b64encode(raw).decode("ascii"), + }) + print(json.dumps({"message_id": message_id, "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/scripts/get_incidents.py b/integrations/gmail/scripts/get_incidents.py new file mode 100644 index 0000000..e8fb14e --- /dev/null +++ b/integrations/gmail/scripts/get_incidents.py @@ -0,0 +1,143 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt +import base64 +from datetime import datetime + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] + + +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: + text = base64.urlsafe_b64decode(data.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_seconds(value): + s = str(value).strip() + if s.isdigit(): + n = int(s) + if n > 10**12: + n //= 1000 + return n + return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()) + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + q = str(inputs.get("query") or "").strip() + after = inputs.get("after") + if after not in (None, ""): + q = (q + " " if q else "") + "after:" + str(_to_epoch_seconds(after)) + max_results = int(inputs.get("max") or 50) + + res = request("GET", base + "/messages", SCOPES, + params={"q": q, "maxResults": max_results}, subject=mailbox) + out = [] + for m in res.get("messages", []) or []: + full = request("GET", base + "/messages/" + urllib.parse.quote(str(m.get("id", "")), safe=""), + SCOPES, params={"format": "full"}, subject=mailbox) + out.append(flatten_message(full)) + print(json.dumps({"result": out})) + + +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/scripts/get_mail.py b/integrations/gmail/scripts/get_mail.py new file mode 100644 index 0000000..267a8ab --- /dev/null +++ b/integrations/gmail/scripts/get_mail.py @@ -0,0 +1,125 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt +import base64 + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] + + +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: + text = base64.urlsafe_b64decode(data.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", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + message_id = str(inputs.get("message_id") or "").strip() + if not message_id: + raise Exception("message_id is required") + + msg = request("GET", base + "/messages/" + urllib.parse.quote(message_id, safe=""), + SCOPES, params={"format": "full"}, subject=mailbox) + print(json.dumps(flatten_message(msg))) + + +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/scripts/get_thread.py b/integrations/gmail/scripts/get_thread.py new file mode 100644 index 0000000..df679e9 --- /dev/null +++ b/integrations/gmail/scripts/get_thread.py @@ -0,0 +1,126 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt +import base64 + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] + + +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: + text = base64.urlsafe_b64decode(data.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", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + thread_id = str(inputs.get("thread_id") or "").strip() + if not thread_id: + raise Exception("thread_id is required") + + thread = request("GET", base + "/threads/" + urllib.parse.quote(thread_id, safe=""), + SCOPES, params={"format": "full"}, subject=mailbox) + messages = [flatten_message(m) for m in thread.get("messages", []) or []] + print(json.dumps({"id": thread_id, "messages": messages})) + + +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/scripts/get_user.py b/integrations/gmail/scripts/get_user.py new file mode 100644 index 0000000..5200709 --- /dev/null +++ b/integrations/gmail/scripts/get_user.py @@ -0,0 +1,79 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/admin.directory.user.readonly"] +ADMIN_BASE = "https://admin.googleapis.com/admin/directory/v1" + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + user_key = str(inputs.get("user_key") or "").strip() + if not user_key: + raise Exception("user_key is required") + + result = request("GET", ADMIN_BASE + "/users/" + urllib.parse.quote(user_key, safe=""), SCOPES) + print(json.dumps(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/scripts/hide_user.py b/integrations/gmail/scripts/hide_user.py new file mode 100644 index 0000000..34efd9e --- /dev/null +++ b/integrations/gmail/scripts/hide_user.py @@ -0,0 +1,80 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/admin.directory.user"] +ADMIN_BASE = "https://admin.googleapis.com/admin/directory/v1" + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + user_key = str(inputs.get("user_key") or "").strip() + if not user_key: + raise Exception("user_key is required") + + result = request("PUT", ADMIN_BASE + "/users/" + urllib.parse.quote(user_key, safe=""), SCOPES, + body={"includeInGlobalAddressList": bool(inputs.get("visible"))}) + print(json.dumps(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/scripts/list_filters.py b/integrations/gmail/scripts/list_filters.py new file mode 100644 index 0000000..a245812 --- /dev/null +++ b/integrations/gmail/scripts/list_filters.py @@ -0,0 +1,77 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.basic"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + result = request("GET", base + "/settings/filters", SCOPES, subject=mailbox) + print(json.dumps(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/scripts/list_labels.py b/integrations/gmail/scripts/list_labels.py new file mode 100644 index 0000000..624eb2d --- /dev/null +++ b/integrations/gmail/scripts/list_labels.py @@ -0,0 +1,77 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + res = request("GET", base + "/labels", SCOPES, subject=mailbox) + print(json.dumps(res)) + + +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/scripts/list_users.py b/integrations/gmail/scripts/list_users.py new file mode 100644 index 0000000..89226c2 --- /dev/null +++ b/integrations/gmail/scripts/list_users.py @@ -0,0 +1,96 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/admin.directory.user.readonly"] +ADMIN_BASE = "https://admin.googleapis.com/admin/directory/v1" + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + max_results = int(inputs.get("max_results") or 100) + if max_results > 500: + max_results = 500 + + domain = str(inputs.get("domain") or "").strip() + customer = "" + if not domain: + default_user = str(_cfg().get("user_id") or "") + if "@" in default_user: + domain = default_user.split("@", 1)[1] + else: + customer = "my_customer" + + params = { + "domain": domain, + "query": inputs.get("query"), + "maxResults": max_results, + "pageToken": inputs.get("page_token"), + "showDeleted": "true" if inputs.get("show_deleted") else None, + "customer": customer, + } + result = request("GET", ADMIN_BASE + "/users", SCOPES, params=params) + print(json.dumps(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/scripts/move_mail.py b/integrations/gmail/scripts/move_mail.py new file mode 100644 index 0000000..e687e94 --- /dev/null +++ b/integrations/gmail/scripts/move_mail.py @@ -0,0 +1,96 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.modify"] + + +def _csv(value): + return [x.strip() for x in str(value or "").split(",") if x.strip()] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + message_id = str(inputs.get("message_id") or "").strip() + if not message_id: + raise Exception("message_id is required") + + add_labels = _csv(inputs.get("add_labels")) + remove_labels = _csv(inputs.get("remove_labels")) + body = {} + if add_labels: + body["addLabelIds"] = add_labels + if remove_labels: + body["removeLabelIds"] = remove_labels + if not body: + raise Exception("Provide add_labels and/or remove_labels") + + res = request("POST", base + "/messages/" + urllib.parse.quote(message_id, safe="") + "/modify", + SCOPES, body=body, subject=mailbox) + print(json.dumps(res)) + + +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/scripts/remove_filter.py b/integrations/gmail/scripts/remove_filter.py new file mode 100644 index 0000000..bb9cad8 --- /dev/null +++ b/integrations/gmail/scripts/remove_filter.py @@ -0,0 +1,83 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.settings.basic"] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + filter_id = str(inputs.get("filter_id") or "").strip() + if not filter_id: + raise Exception("filter_id is required") + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox) + + result = request("DELETE", base + "/settings/filters/" + urllib.parse.quote(filter_id, safe=""), + SCOPES, subject=mailbox) + if not result: + result = {"ok": True, "filter_id": filter_id} + print(json.dumps(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/scripts/search.py b/integrations/gmail/scripts/search.py new file mode 100644 index 0000000..c862fbb --- /dev/null +++ b/integrations/gmail/scripts/search.py @@ -0,0 +1,143 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt +import base64 + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] + + +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: + text = base64.urlsafe_b64decode(data.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 _bool(value): + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "y") + return bool(value) + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + max_results = int(inputs.get("max_results") or 100) + if max_results > 500: + max_results = 500 + params = { + "q": inputs.get("query"), + "maxResults": max_results, + "pageToken": inputs.get("page_token"), + "labelIds": inputs.get("label_id"), + } + if inputs.get("include_spam_trash") not in (None, ""): + params["includeSpamTrash"] = "true" if _bool(inputs.get("include_spam_trash")) else "false" + + res = request("GET", base + "/messages", SCOPES, params=params, subject=mailbox) + out = [] + for m in res.get("messages", []) or []: + full = request("GET", base + "/messages/" + urllib.parse.quote(str(m.get("id", "")), safe=""), + SCOPES, params={"format": "full"}, subject=mailbox) + out.append(flatten_message(full)) + print(json.dumps({"result": out, "next_page_token": res.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/scripts/send_mail.py b/integrations/gmail/scripts/send_mail.py new file mode 100644 index 0000000..9e371bb --- /dev/null +++ b/integrations/gmail/scripts/send_mail.py @@ -0,0 +1,133 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt +import base64 +import mimetypes +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" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = [ + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.send", +] + + +def _build_message(inputs, mailbox): + body = str(inputs.get("body") or "") + html_body = str(inputs.get("html_body") or "") + att_name = str(inputs.get("attachment_name") or "") + att_b64 = str(inputs.get("attachment_base64") or "") + + if html_body or att_b64: + message = MIMEMultipart("mixed" if att_b64 else "alternative") + if body: + message.attach(MIMEText(body, "plain", "utf-8")) + if html_body: + message.attach(MIMEText(html_body, "html", "utf-8")) + if att_b64: + name = att_name or "attachment.bin" + ctype = mimetypes.guess_type(name)[0] or "application/octet-stream" + main_type, sub_type = ctype.split("/", 1) + part = MIMEBase(main_type, sub_type) + part.set_payload(base64.b64decode(att_b64)) + encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment", filename=name) + message.attach(part) + else: + message = MIMEText(body, "plain", "utf-8") + + message["From"] = mailbox + message["To"] = str(inputs.get("to") or "") + if inputs.get("cc"): + message["Cc"] = str(inputs["cc"]) + if inputs.get("bcc"): + message["Bcc"] = str(inputs["bcc"]) + message["Subject"] = str(inputs.get("subject") or "") + if inputs.get("reply_to"): + message["Reply-To"] = str(inputs["reply_to"]) + if inputs.get("in_reply_to"): + message["In-Reply-To"] = str(inputs["in_reply_to"]) + if inputs.get("references"): + message["References"] = str(inputs["references"]) + return message + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + mailbox = str(inputs.get("user_id") or _cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + if not str(inputs.get("to") or "").strip(): + raise Exception("to is required") + if not str(inputs.get("subject") or "").strip(): + raise Exception("subject is required") + + message = _build_message(inputs, mailbox) + raw = base64.urlsafe_b64encode(message.as_bytes()).decode("ascii") + res = request("POST", base + "/messages/send", SCOPES, body={"raw": raw}, subject=mailbox) + print(json.dumps(res)) + + +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/scripts/set_password.py b/integrations/gmail/scripts/set_password.py new file mode 100644 index 0000000..00b4554 --- /dev/null +++ b/integrations/gmail/scripts/set_password.py @@ -0,0 +1,83 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/admin.directory.user"] +ADMIN_BASE = "https://admin.googleapis.com/admin/directory/v1" + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + user_key = str(inputs.get("user_key") or "").strip() + if not user_key: + raise Exception("user_key is required") + password = str(inputs.get("password") or "") + if not password: + raise Exception("password is required") + + result = request("PUT", ADMIN_BASE + "/users/" + urllib.parse.quote(user_key, safe=""), SCOPES, + body={"password": password}) + print(json.dumps(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/scripts/test_connection.py b/integrations/gmail/scripts/test_connection.py new file mode 100644 index 0000000..996abb4 --- /dev/null +++ b/integrations/gmail/scripts/test_connection.py @@ -0,0 +1,78 @@ +import json, os, sys, time, urllib.parse, urllib.request, urllib.error + +import jwt + +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _token(scopes, subject=None): + cfg = _cfg() + raw = cfg.get("service_account_json", "") + sa = json.loads(raw) if isinstance(raw, str) else raw + if not sa.get("client_email") or not sa.get("private_key"): + raise Exception("service_account_json must contain client_email and private_key") + now = int(time.time()) + aud = sa.get("token_uri") or TOKEN_URL + payload = { + "iss": sa["client_email"], + "scope": " ".join(scopes), + "aud": aud, + "iat": now, + "exp": now + 3600, + } + sub = subject or cfg.get("user_id") or "" + if sub: + payload["sub"] = sub + assertion = jwt.encode(payload, sa["private_key"], algorithm="RS256") + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }).encode("utf-8") + req = urllib.request.Request(aud, data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, 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 request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def request(method, url, scopes, params=None, body=None, subject=None): + 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(scopes, subject)} + 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 {} + + +SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] + + +def main(): + mailbox = str(_cfg().get("user_id") or "me") + base = "https://gmail.googleapis.com/gmail/v1/users/" + urllib.parse.quote(mailbox, safe="") + + res = request("GET", base + "/profile", SCOPES, subject=mailbox) + if not res.get("emailAddress"): + raise Exception("Profile response missing emailAddress: " + json.dumps(res)) + print(json.dumps({"ok": True, "email": res["emailAddress"]})) + + +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)