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,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)