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
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:
@@ -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. =========
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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", [])
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
)
|
||||
Reference in New Issue
Block a user