chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
+30
View File
@@ -0,0 +1,30 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from datetime import date, timedelta
def date_dir_to_gmail_query(name: str) -> str | None:
parts = name.split("-")
if len(parts) != 3:
return None
if not (len(parts[0]) == 4 and len(parts[1]) == 2 and len(parts[2]) == 2):
return None
try:
d = date(int(parts[0]), int(parts[1]), int(parts[2]))
except ValueError:
return None
nxt = d + timedelta(days=1)
return (f"after:{d.year}/{d.month:02d}/{d.day:02d} "
f"before:{nxt.year}/{nxt.month:02d}/{nxt.day:02d}")
+29
View File
@@ -0,0 +1,29 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.accessor.gmail import GmailAccessor
from mirage.cache.index import IndexCacheStore
from mirage.commands.builtin.constants import SCOPE_ERROR
from mirage.core.gmail.readdir import readdir
from mirage.types import PathSpec
from mirage.utils.glob_walk import resolve_glob_with
async def resolve_glob(
accessor: GmailAccessor,
paths: list[PathSpec],
index: IndexCacheStore,
) -> list[PathSpec]:
return await resolve_glob_with(readdir, accessor, paths, index,
SCOPE_ERROR)
+29
View File
@@ -0,0 +1,29 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.core.google._client import GMAIL_API_BASE, TokenManager, google_get
async def list_labels(token_manager: TokenManager) -> list[dict]:
"""List all Gmail labels.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
Returns:
list[dict]: list of label objects.
"""
url = f"{GMAIL_API_BASE}/users/me/labels"
data = await google_get(token_manager, url)
return data.get("labels", [])
+209
View File
@@ -0,0 +1,209 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
from typing import Any
from mirage.core.google._client import (GMAIL_API_BASE, TokenManager,
google_get, google_post)
async def list_messages(
token_manager: TokenManager,
label_id: str | None = None,
query: str | None = None,
max_results: int = 50,
) -> list[dict]:
"""List message IDs for a label or query.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
label_id (str | None): Gmail label ID to filter by.
query (str | None): Gmail search query.
max_results (int): maximum number of results.
Returns:
list[dict]: list of message stubs with "id" and "threadId".
"""
params: dict[str, Any] = {"maxResults": max_results}
if label_id:
params["labelIds"] = label_id
if query:
params["q"] = query
url = f"{GMAIL_API_BASE}/users/me/messages"
data = await google_get(token_manager, url, params=params)
return data.get("messages", [])
async def trash_message(
token_manager: TokenManager,
message_id: str,
) -> None:
"""Move a Gmail message to Trash.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
message_id (str): Gmail message ID.
"""
url = f"{GMAIL_API_BASE}/users/me/messages/{message_id}/trash"
await google_post(token_manager, url, json={})
async def get_message_raw(
token_manager: TokenManager,
message_id: str,
) -> dict:
"""Get raw message JSON from API.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
message_id (str): Gmail message ID.
Returns:
dict: full message resource.
"""
url = f"{GMAIL_API_BASE}/users/me/messages/{message_id}?format=full"
return await google_get(token_manager, url)
def _decode_body(payload: dict) -> str:
if payload.get("mimeType") == "text/plain":
data = payload.get("body", {}).get("data", "")
if data:
return base64.urlsafe_b64decode(data + "==").decode(
"utf-8", errors="replace")
for part in payload.get("parts", []):
text = _decode_body(part)
if text:
return text
return ""
def _extract_header(headers: list[dict], name: str) -> str:
for h in headers:
if h.get("name", "").lower() == name.lower():
return h.get("value", "")
return ""
def _parse_address(raw: str) -> dict[str, str]:
if "<" in raw and ">" in raw:
name = raw[:raw.index("<")].strip().strip('"')
email = raw[raw.index("<") + 1:raw.index(">")].strip()
return {"name": name, "email": email}
return {"name": "", "email": raw.strip()}
def _parse_address_list(raw: str) -> list[dict[str, str]]:
if not raw:
return []
return [_parse_address(a.strip()) for a in raw.split(",")]
async def get_attachment(
token_manager: TokenManager,
message_id: str,
attachment_id: str,
) -> bytes:
"""Fetch and decode an attachment.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
message_id (str): Gmail message ID.
attachment_id (str): Gmail attachment ID.
Returns:
bytes: decoded attachment content.
"""
url = (f"{GMAIL_API_BASE}/users/me/messages"
f"/{message_id}/attachments/{attachment_id}")
data = await google_get(token_manager, url)
raw = data.get("data", "")
return base64.urlsafe_b64decode(raw + "==")
def _extract_attachments(payload: dict) -> list[dict]:
"""Extract attachment metadata from message payload.
Args:
payload (dict): message payload from Gmail API.
Returns:
list[dict]: attachment info dicts.
"""
attachments = []
parts = payload.get("parts", [])
for part in parts:
filename = part.get("filename", "")
body = part.get("body", {})
attachment_id = body.get("attachmentId", "")
if filename and attachment_id:
attachments.append({
"filename": filename,
"attachment_id": attachment_id,
"size": body.get("size", 0),
"mime_type": part.get("mimeType", ""),
})
if part.get("parts"):
for sub in part["parts"]:
fn = sub.get("filename", "")
bd = sub.get("body", {})
aid = bd.get("attachmentId", "")
if fn and aid:
attachments.append({
"filename": fn,
"attachment_id": aid,
"size": bd.get("size", 0),
"mime_type": sub.get("mimeType", ""),
})
return attachments
async def get_message_processed(
token_manager: TokenManager,
message_id: str,
) -> dict:
"""Get message as processed dict with decoded body.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
message_id (str): Gmail message ID.
Returns:
dict: processed message with decoded body text.
"""
raw = await get_message_raw(token_manager, message_id)
headers = raw.get("payload", {}).get("headers", [])
body_text = _decode_body(raw.get("payload", {}))
raw_atts = _extract_attachments(raw.get("payload", {}))
attachments = [{
"id": a["attachment_id"],
"filename": a["filename"],
"path": f"attachments/{a['attachment_id']}_{a['filename']}",
"mime_type": a.get("mime_type", ""),
"size": a["size"],
} for a in raw_atts]
return {
"id": raw.get("id", ""),
"thread_id": raw.get("threadId", ""),
"from": _parse_address(_extract_header(headers, "From")),
"to": _parse_address_list(_extract_header(headers, "To")),
"cc": _parse_address_list(_extract_header(headers, "Cc")),
"subject": _extract_header(headers, "Subject"),
"date": _extract_header(headers, "Date"),
"body_text": body_text,
"snippet": raw.get("snippet", ""),
"labels": raw.get("labelIds", []),
"attachments": attachments,
}
+70
View File
@@ -0,0 +1,70 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import json
import posixpath
from mirage.accessor.gmail import GmailAccessor
from mirage.cache.index import IndexCacheStore
from mirage.core.gmail.messages import get_attachment, get_message_processed
from mirage.core.gmail.readdir import readdir
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.key_prefix import mount_key, mount_prefix_of
async def read(
accessor: GmailAccessor,
path: PathSpec,
index: IndexCacheStore = None,
) -> bytes:
if isinstance(path, str):
path = PathSpec(virtual=path,
directory=path,
resource_path=path.strip("/"))
virtual = path.virtual
prefix = mount_prefix_of(path.virtual, path.resource_path)
key = path.resource_path
if index is None:
raise enoent(virtual)
virtual_key = prefix + "/" + key if prefix else "/" + key
result = await index.get(virtual_key)
if result.entry is None:
parent_key = posixpath.dirname(virtual_key) or "/"
if parent_key != virtual_key:
parent_path = PathSpec.from_str_path(parent_key,
mount_key(parent_key, prefix))
try:
await readdir(accessor, parent_path, index)
result = await index.get(virtual_key)
except Exception:
pass
if result.entry is None:
raise enoent(virtual)
if result.entry.resource_type in ("gmail/label", "gmail/date",
"gmail/attachment_dir"):
raise IsADirectoryError(virtual)
if result.entry.resource_type == "gmail/attachment":
att_dir_key = posixpath.dirname(virtual_key)
att_dir_result = await index.get(att_dir_key)
if att_dir_result.entry is None:
raise enoent(virtual)
message_id = att_dir_result.entry.id
attachment_id = result.entry.id
return await get_attachment(accessor.token_manager, message_id,
attachment_id)
processed = await get_message_processed(accessor.token_manager,
result.entry.id)
return json.dumps(processed, ensure_ascii=False,
separators=(",", ":")).encode()
+299
View File
@@ -0,0 +1,299 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import logging
import re
from datetime import datetime, timezone
from mirage.accessor.gmail import GmailAccessor
from mirage.cache.index import IndexCacheStore, IndexEntry
from mirage.core.gmail.date_query import date_dir_to_gmail_query
from mirage.core.gmail.labels import list_labels
from mirage.core.gmail.messages import (_extract_attachments, _extract_header,
get_message_raw, list_messages)
from mirage.core.google.drive import GoogleFileSuffix
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.key_prefix import mount_key, mount_prefix_of
logger = logging.getLogger(__name__)
def is_dir_name(child: str) -> bool:
# readdir emits only label/date dirs and rendered *.gmail.json files.
return not child.endswith(GoogleFileSuffix.GMAIL.value)
TITLE_MAX = 80
UNSAFE = re.compile(r"[^\w\s\-.]")
MULTI_UNDERSCORE = re.compile(r"_+")
def _sanitize(text: str) -> str:
if not text.strip():
return "No_Subject"
cleaned = UNSAFE.sub("_", text).replace(" ", "_")
cleaned = MULTI_UNDERSCORE.sub("_", cleaned).strip("_")
if len(cleaned) > TITLE_MAX:
cleaned = cleaned[:TITLE_MAX - 3] + "..."
return cleaned
def _msg_filename(subject: str, msg_id: str) -> str:
return f"{_sanitize(subject)}__{msg_id}.gmail.json"
def _attach_dir_name(subject: str, msg_id: str) -> str:
return f"{_sanitize(subject)}__{msg_id}"
def _attachment_filename(_attachment_id: str, filename: str) -> str:
return filename or "file"
def _date_from_internal(internal_date: str) -> str:
ts = int(internal_date) / 1000
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
async def _build_date_groups(
accessor: GmailAccessor,
msg_ids: list[dict],
index: IndexCacheStore | None,
virtual_key: str,
write_dates: bool,
) -> list[tuple[str, IndexEntry]]:
date_groups: dict[str, list[dict]] = {}
for m in msg_ids:
mid = m["id"]
raw = await get_message_raw(accessor.token_manager, mid)
internal_date = raw.get("internalDate", "0")
date_str = _date_from_internal(internal_date)
date_groups.setdefault(date_str, []).append(raw)
date_entries: list[tuple[str, IndexEntry]] = []
for date_str in sorted(date_groups.keys(), reverse=True):
date_entry = IndexEntry(
id=date_str,
name=date_str,
resource_type="gmail/date",
vfs_name=date_str,
)
date_entries.append((date_str, date_entry))
date_children: list[tuple[str, IndexEntry]] = []
for raw in date_groups[date_str]:
mid = raw["id"]
headers = raw.get("payload", {}).get("headers", [])
subject = _extract_header(headers, "Subject") or "No Subject"
filename = _msg_filename(subject, mid)
msg_entry = IndexEntry(
id=mid,
name=subject,
resource_type="gmail/message",
vfs_name=filename,
size=raw.get("sizeEstimate"),
)
date_children.append((filename, msg_entry))
attachments = _extract_attachments(raw.get("payload", {}))
if attachments:
att_dir = _attach_dir_name(subject, mid)
att_dir_entry = IndexEntry(
id=mid,
name=att_dir,
resource_type="gmail/attachment_dir",
vfs_name=att_dir,
)
date_children.append((att_dir, att_dir_entry))
att_entries: list[tuple[str, IndexEntry]] = []
for att in attachments:
att_name = _attachment_filename(att["attachment_id"],
att["filename"])
att_entry = IndexEntry(
id=att["attachment_id"],
name=att["filename"],
resource_type="gmail/attachment",
vfs_name=att_name,
size=att["size"],
)
att_entries.append((att_name, att_entry))
att_vkey = virtual_key + "/" + date_str + "/" + att_dir
if index is not None and write_dates:
await index.set_dir(att_vkey, att_entries)
if index is not None and write_dates:
await index.set_dir(virtual_key + "/" + date_str, date_children)
return date_entries
async def readdir(
accessor: GmailAccessor,
path: PathSpec,
index: IndexCacheStore = None,
) -> list[str]:
if isinstance(path, str):
path = PathSpec(virtual=path,
directory=path,
resource_path=path.strip("/"))
virtual = path.virtual
prefix = mount_prefix_of(path.virtual, path.resource_path)
path = (path.dir if path.pattern else path).mount_path
key = path.strip("/")
virtual_key = prefix + "/" + key if key else prefix or "/"
parts = key.split("/") if key else []
depth = len(parts)
if depth == 0:
if index is not None:
cached = await index.list_dir(virtual_key)
if cached.entries is not None:
return cached.entries
labels = await list_labels(accessor.token_manager)
entries = []
for lb in labels:
if lb.get("type") == "system":
name = lb["id"]
else:
name = lb.get("name", lb["id"])
entry = IndexEntry(
id=lb["id"],
name=name,
resource_type="gmail/label",
vfs_name=name,
)
entries.append((name, entry))
if index is not None:
await index.set_dir(virtual_key, entries)
return [f"{prefix}/{name}" for name, _ in entries]
if depth == 1:
label_name = parts[0]
if index is not None:
cached = await index.list_dir(virtual_key)
if cached.entries is not None:
return cached.entries
if index is None:
raise enoent(virtual)
label_key = prefix + "/" + label_name if prefix else "/" + label_name
result = await index.get(label_key)
if result.entry is None:
# Auto-bootstrap: populate label index.
try:
root = PathSpec(
virtual=prefix or "/",
directory=prefix or "/",
resource_path=mount_key(prefix or "/", prefix),
)
await readdir(accessor, root, index)
result = await index.get(label_key)
except Exception as e:
logger.debug(
"gmail readdir: bootstrap failed for %s: %s",
label_key,
e,
)
if result.entry is None:
raise enoent(virtual)
label_id = result.entry.id
msg_ids = await list_messages(
accessor.token_manager,
label_id=label_id,
max_results=50,
)
date_entries = await _build_date_groups(
accessor,
msg_ids,
index,
virtual_key,
write_dates=True,
)
if index is not None:
await index.set_dir(virtual_key, date_entries)
return [f"{prefix}/{key}/{name}" for name, _ in date_entries]
if depth == 2:
if index is None:
raise enoent(virtual)
cached = await index.list_dir(virtual_key)
if cached.entries is not None:
return cached.entries
label_name = parts[0]
date_str = parts[1]
date_query = date_dir_to_gmail_query(date_str)
if date_query is not None:
label_key = (prefix + "/" + label_name if prefix else "/" +
label_name)
label_result = await index.get(label_key)
if label_result.entry is None:
try:
root = PathSpec(
virtual=prefix or "/",
directory=prefix or "/",
resource_path=mount_key(prefix or "/", prefix),
)
await readdir(accessor, root, index)
label_result = await index.get(label_key)
except Exception as e:
logger.debug(
"gmail readdir: date-dir bootstrap failed for %s: %s",
label_key,
e,
)
if label_result.entry is not None:
label_id = label_result.entry.id
label_vkey = label_key
msg_ids = await list_messages(
accessor.token_manager,
label_id=label_id,
query=date_query,
max_results=50,
)
await _build_date_groups(
accessor,
msg_ids,
index,
label_vkey,
write_dates=True,
)
if not msg_ids:
await index.set_dir(virtual_key, [])
cached = await index.list_dir(virtual_key)
if cached.entries is not None:
return cached.entries
raise enoent(virtual)
label_path = prefix + "/" + parts[0] if prefix else "/" + parts[0]
await readdir(
accessor,
PathSpec.from_str_path(label_path, mount_key(label_path, prefix)),
index)
cached = await index.list_dir(virtual_key)
if cached.entries is not None:
return cached.entries
raise enoent(virtual)
if depth == 3:
if index is None:
raise enoent(virtual)
cached = await index.list_dir(virtual_key)
if cached.entries is not None:
return cached.entries
date_path = (prefix + "/" + parts[0] + "/" +
parts[1] if prefix else "/" + parts[0] + "/" + parts[1])
await readdir(
accessor,
PathSpec.from_str_path(date_path, mount_key(date_path, prefix)),
index)
cached = await index.list_dir(virtual_key)
if cached.entries is not None:
return cached.entries
raise enoent(virtual)
raise enoent(virtual)
+75
View File
@@ -0,0 +1,75 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from dataclasses import dataclass
from mirage.types import PathSpec
from mirage.utils.key_prefix import mount_prefix_of
@dataclass
class GmailScope:
use_native: bool
label_name: str | None = None
date_str: str | None = None
resource_path: str = "/"
def detect_scope(path: PathSpec) -> GmailScope:
if isinstance(path, str):
path = PathSpec(virtual=path,
directory=path,
resource_path=path.strip("/"))
prefix = mount_prefix_of(path.virtual, path.resource_path) or ""
if path.pattern and path.pattern.endswith(".gmail.json"):
dir_key = path.directory.strip("/")
if prefix:
dir_key = dir_key.removeprefix(prefix.strip("/") + "/")
parts = dir_key.split("/") if dir_key else []
if len(parts) == 2:
return GmailScope(
use_native=True,
label_name=parts[0],
date_str=parts[1],
resource_path=dir_key,
)
key = path.resource_path
if not key:
return GmailScope(use_native=True, resource_path="/")
parts = key.split("/")
if len(parts) == 1:
return GmailScope(
use_native=True,
label_name=parts[0],
resource_path=key,
)
if len(parts) == 2:
return GmailScope(
use_native=True,
label_name=parts[0],
date_str=parts[1],
resource_path=key,
)
return GmailScope(
use_native=False,
label_name=parts[0],
date_str=parts[1] if len(parts) >= 2 else None,
resource_path=key,
)
+134
View File
@@ -0,0 +1,134 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from datetime import datetime, timezone
from mirage.core.gmail.messages import (_decode_body, _extract_header,
get_message_processed, get_message_raw,
list_messages)
from mirage.core.gmail.readdir import _sanitize
from mirage.core.gmail.scope import GmailScope
from mirage.core.google._client import TokenManager
EXCERPT_WINDOW = 120
EXCERPT_MAX = 240
def _extract_excerpt(text: str, pattern: str) -> str:
if not text or not pattern:
return ""
flat = " ".join(text.split())
idx = flat.lower().find(pattern.lower())
if idx < 0:
return flat[:EXCERPT_MAX]
start = max(0, idx - EXCERPT_WINDOW)
end = min(len(flat), idx + len(pattern) + EXCERPT_WINDOW)
prefix = "..." if start > 0 else ""
suffix = "..." if end < len(flat) else ""
return f"{prefix}{flat[start:end]}{suffix}"
def _build_query(pattern: str, label_name: str | None,
date_str: str | None) -> str:
parts = [pattern]
if label_name:
parts.append(f"label:{label_name}")
if date_str:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
parts.append(f"after:{dt.strftime('%Y/%m/%d')}")
except ValueError:
pass
return " ".join(parts)
def _date_from_internal(internal_date: str) -> str:
try:
ts = int(internal_date) / 1000
except (TypeError, ValueError):
return ""
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
async def search_messages(
token_manager: TokenManager,
pattern: str,
label_name: str | None = None,
date_str: str | None = None,
max_results: int = 50,
) -> list[dict]:
"""Search Gmail and return formatted result rows.
Returns:
list[dict]: each row has keys path, subject, snippet, sender.
"""
query = _build_query(pattern, label_name, date_str)
stubs = await list_messages(
token_manager,
query=query,
max_results=max_results,
)
rows: list[dict] = []
for stub in stubs:
mid = stub.get("id")
if not mid:
continue
raw = await get_message_raw(token_manager, mid)
headers = raw.get("payload", {}).get("headers", [])
subject = _extract_header(headers, "Subject") or "No Subject"
sender = _extract_header(headers, "From") or "?"
snippet = raw.get("snippet", "")
body_text = _decode_body(raw.get("payload", {}))
msg_date = _date_from_internal(raw.get("internalDate", "0"))
rows.append({
"id": mid,
"subject": subject,
"snippet": snippet,
"sender": sender,
"date": msg_date,
"label": label_name or "",
"body_text": body_text,
})
return rows
def format_grep_results(
rows: list[dict],
scope: GmailScope,
prefix: str,
pattern: str = "",
) -> list[str]:
lines: list[str] = []
for row in rows:
label = row.get("label") or scope.label_name or "INBOX"
date = row.get("date", "")
mid = row.get("id", "")
subject_clean = _sanitize(row.get("subject") or "No Subject")
filename = f"{subject_clean}__{mid}.gmail.json"
sender = row.get("sender", "?")
haystack = f"{row.get('subject', '')}\n{row.get('body_text', '')}"
excerpt = _extract_excerpt(haystack, pattern) if pattern else ""
if not excerpt:
excerpt = (row.get("snippet") or "").replace("\n", " ")
path = (f"{prefix}/{label}/{date}/{filename}"
if date else f"{prefix}/{label}/{filename}")
lines.append(f"{path}:[{sender}] {excerpt}")
return lines
__all__ = [
"search_messages",
"get_message_processed",
"format_grep_results",
]
+157
View File
@@ -0,0 +1,157 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
from email.mime.text import MIMEText
from mirage.core.gmail.messages import (_extract_header, get_message_processed,
get_message_raw)
from mirage.core.google._client import (GMAIL_API_BASE, TokenManager,
google_post)
async def send_message(
token_manager: TokenManager,
to: str,
subject: str,
body: str,
) -> dict:
"""Send a new email.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
to (str): recipient email address.
subject (str): email subject.
body (str): plain-text email body.
Returns:
dict: API response with sent message metadata.
"""
msg = MIMEText(body)
msg["To"] = to
msg["Subject"] = subject
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
url = f"{GMAIL_API_BASE}/users/me/messages/send"
return await google_post(token_manager, url, {"raw": raw})
async def reply_message(
token_manager: TokenManager,
message_id: str,
body: str,
) -> dict:
"""Reply to a message (preserves threading).
Args:
token_manager (TokenManager): manages OAuth2 tokens.
message_id (str): ID of the message to reply to.
body (str): plain-text reply body.
Returns:
dict: API response with sent message metadata.
"""
raw_msg = await get_message_raw(token_manager, message_id)
headers = raw_msg.get("payload", {}).get("headers", [])
to = _extract_header(headers, "From")
subject = _extract_header(headers, "Subject")
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
thread_id = raw_msg.get("threadId", "")
msg_id_header = _extract_header(headers, "Message-ID")
mime = MIMEText(body)
mime["To"] = to
mime["Subject"] = subject
if msg_id_header:
mime["In-Reply-To"] = msg_id_header
mime["References"] = msg_id_header
raw = base64.urlsafe_b64encode(mime.as_bytes()).decode()
payload: dict = {"raw": raw}
if thread_id:
payload["threadId"] = thread_id
url = f"{GMAIL_API_BASE}/users/me/messages/send"
return await google_post(token_manager, url, payload)
async def reply_all_message(
token_manager: TokenManager,
message_id: str,
body: str,
) -> dict:
"""Reply-all to a message (preserves threading, includes all recipients).
Args:
token_manager (TokenManager): manages OAuth2 tokens.
message_id (str): ID of the message to reply to.
body (str): plain-text reply body.
Returns:
dict: API response with sent message metadata.
"""
raw_msg = await get_message_raw(token_manager, message_id)
headers = raw_msg.get("payload", {}).get("headers", [])
sender = _extract_header(headers, "From")
original_to = _extract_header(headers, "To")
original_cc = _extract_header(headers, "Cc")
subject = _extract_header(headers, "Subject")
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
thread_id = raw_msg.get("threadId", "")
msg_id_header = _extract_header(headers, "Message-ID")
all_to = ", ".join(filter(None, [sender, original_to]))
mime = MIMEText(body)
mime["To"] = all_to
if original_cc:
mime["Cc"] = original_cc
mime["Subject"] = subject
if msg_id_header:
mime["In-Reply-To"] = msg_id_header
mime["References"] = msg_id_header
raw = base64.urlsafe_b64encode(mime.as_bytes()).decode()
payload: dict = {"raw": raw}
if thread_id:
payload["threadId"] = thread_id
url = f"{GMAIL_API_BASE}/users/me/messages/send"
return await google_post(token_manager, url, payload)
async def forward_message(
token_manager: TokenManager,
message_id: str,
to: str,
) -> dict:
"""Forward a message.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
message_id (str): ID of the message to forward.
to (str): recipient email address.
Returns:
dict: API response with sent message metadata.
"""
processed = await get_message_processed(token_manager, message_id)
subject = processed.get("subject", "")
if not subject.lower().startswith("fwd:"):
subject = f"Fwd: {subject}"
fwd_body = (f"---------- Forwarded message ----------\n"
f"From: {processed['from'].get('email', '')}\n"
f"Date: {processed.get('date', '')}\n"
f"Subject: {processed.get('subject', '')}\n\n"
f"{processed.get('body_text', '')}")
return await send_message(token_manager, to, subject, fwd_body)
+113
View File
@@ -0,0 +1,113 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mimetypes import guess_type as _guess_mime
from mirage.accessor.gmail import GmailAccessor
from mirage.cache.index import IndexCacheStore
from mirage.core.gmail.labels import list_labels
from mirage.core.gmail.readdir import readdir as _readdir
from mirage.types import FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from mirage.utils.filetype import filetype_from_mimetype
from mirage.utils.key_prefix import mount_key, mount_prefix_of
def _guess_filetype(filename: str) -> FileType:
mime, _ = _guess_mime(filename)
return filetype_from_mimetype(mime or "")
async def stat(
accessor: GmailAccessor,
path: PathSpec,
index: IndexCacheStore = None,
) -> FileStat:
if isinstance(path, str):
path = PathSpec(virtual=path,
directory=path,
resource_path=path.strip("/"))
virtual = path.virtual
prefix = mount_prefix_of(path.virtual, path.resource_path)
key = path.resource_path
if not key:
return FileStat(name="/", type=FileType.DIRECTORY)
if index is None:
raise enoent(virtual)
virtual_key = prefix + "/" + key if prefix else "/" + key
result = await index.get(virtual_key)
if result.entry is None and "/" not in key:
labels = await list_labels(accessor.token_manager)
names = {
lb["id"] if lb.get("type") == "system" else lb.get(
"name", lb["id"])
for lb in labels
}
if key in names:
return FileStat(name=key, type=FileType.DIRECTORY)
raise enoent(virtual)
if result.entry is None:
parent_virtual = virtual_key.rsplit("/", 1)[0] or "/"
try:
await _readdir(
accessor,
PathSpec(virtual=parent_virtual,
directory=parent_virtual,
resource_path=mount_key(parent_virtual, prefix)),
index=index,
)
# best-effort cache populate; canonical ENOENT raised below
except Exception:
pass
result = await index.get(virtual_key)
if result.entry is None:
raise enoent(virtual)
rt = result.entry.resource_type
if rt == "gmail/label":
return FileStat(
name=result.entry.vfs_name,
type=FileType.DIRECTORY,
extra={"label_id": result.entry.id},
)
if rt == "gmail/date":
return FileStat(
name=result.entry.vfs_name,
type=FileType.DIRECTORY,
)
if rt == "gmail/message":
return FileStat(
name=result.entry.vfs_name,
type=FileType.JSON,
size=result.entry.size,
extra={"message_id": result.entry.id},
)
if rt == "gmail/attachment_dir":
return FileStat(
name=result.entry.vfs_name,
type=FileType.DIRECTORY,
extra={"message_id": result.entry.id},
)
if rt == "gmail/attachment":
ft = _guess_filetype(result.entry.vfs_name)
return FileStat(
name=result.entry.vfs_name,
type=ft,
size=result.entry.size,
extra={"attachment_id": result.entry.id},
)
return FileStat(
name=result.entry.vfs_name,
type=FileType.JSON,
extra={"message_id": result.entry.id},
)