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