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:
Guillaume BOURGEOIS
2026-07-10 23:29:12 +02:00
parent 27615636e3
commit 84be770928
12 changed files with 1027 additions and 0 deletions
@@ -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)