feat(phantom-ship): hara-gmail-mcp server (path 1, IMAP+SMTP) 📬

Adds an MCP server exposing read tools (list_inbox, search, read_email)
across three personal Gmail accounts using existing app passwords in
/etc/openclaw/. Wired into claude-channels via --mcp-config. Slated for
replacement by an OAuth2 Gmail+Calendar server in path 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DannyDannyDanny 2026-05-02 14:15:10 +02:00
parent 771cc58076
commit af9f735abc
9 changed files with 559 additions and 3 deletions

View file

@ -0,0 +1,112 @@
"""Account config loader.
Reads a JSON file (default: /etc/hara/gmail-accounts.json) listing the Gmail
accounts Hara can act on, and the path to each account's IMAP/SMTP app
password. Passwords are loaded once via `sudo -n cat` because the password
files are root:991 0640 and the MCP server process runs as `danny`. The
result is cached in memory for the process lifetime.
Schema:
{
"accounts": [
{
"email": "user@example.com",
"password_file": "/etc/openclaw/gmail-user-app-password",
"imap_host": "imap.gmail.com",
"imap_port": 993,
"smtp_host": "smtp.gmail.com",
"smtp_port": 465
}
]
}
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
DEFAULT_CONFIG_PATH = "/etc/hara/gmail-accounts.json"
# NixOS keeps the setuid sudo wrapper at /run/wrappers/bin; non-NixOS distros
# put it in /usr/bin or /bin. We try $PATH first, then fall back to these.
_SUDO_FALLBACKS = ["/run/wrappers/bin/sudo", "/usr/bin/sudo", "/bin/sudo"]
def _find_sudo() -> str:
found = shutil.which("sudo")
if found:
return found
for candidate in _SUDO_FALLBACKS:
if Path(candidate).exists():
return candidate
raise RuntimeError(
"sudo not found on PATH or in known locations; "
"cannot read group-restricted password files"
)
@dataclass(frozen=True)
class Account:
email: str
password_file: str
imap_host: str
imap_port: int
smtp_host: str
smtp_port: int
class AccountStore:
"""Holds account metadata and lazily resolves passwords on first use."""
def __init__(self, accounts: list[Account]) -> None:
self._accounts = {a.email: a for a in accounts}
self._password_cache: dict[str, str] = {}
@classmethod
def from_config_file(cls, path: str | os.PathLike[str] | None = None) -> "AccountStore":
config_path = Path(path or os.environ.get("HARA_GMAIL_CONFIG", DEFAULT_CONFIG_PATH))
with config_path.open() as f:
data = json.load(f)
accounts = [
Account(
email=a["email"],
password_file=a["password_file"],
imap_host=a.get("imap_host", "imap.gmail.com"),
imap_port=int(a.get("imap_port", 993)),
smtp_host=a.get("smtp_host", "smtp.gmail.com"),
smtp_port=int(a.get("smtp_port", 465)),
)
for a in data.get("accounts", [])
]
return cls(accounts)
def emails(self) -> list[str]:
return list(self._accounts.keys())
def get(self, email: str) -> Account:
try:
return self._accounts[email]
except KeyError:
raise ValueError(f"Unknown account: {email!r}. Configured: {self.emails()}")
def password_for(self, email: str) -> str:
if email in self._password_cache:
return self._password_cache[email]
account = self.get(email)
# Prefer direct read if the file is reachable (e.g. after path 2
# migration where the daemon owns its own creds), fall back to
# `sudo -n cat` for the current /etc/openclaw/ layout.
try:
value = Path(account.password_file).read_text().strip()
except PermissionError:
value = subprocess.check_output(
[_find_sudo(), "-n", "cat", account.password_file],
text=True,
).strip()
if not value:
raise RuntimeError(f"Empty password file for {email}: {account.password_file}")
self._password_cache[email] = value
return value