Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
push:
branches: [master, main]
pull_request:
branches: [master, main]

env:
CARGO_TERM_COLOR: always

jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features

clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy -- -D warnings

fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,74 @@ cargo test
cargo run --release
```

## Troubleshooting

### OAuth Authorization Failed

**Symptom:** Browser opens but authorization fails or redirects to error page.

**Solutions:**
1. Ensure your Google Cloud project has Gmail API enabled
2. Check that OAuth credentials are "Desktop application" type
3. Verify `credentials.json` is in `~/.fgp/auth/google/`
4. Try deleting `~/.fgp/auth/google/gmail_token.pickle` and re-authorizing

### Token Expired / Invalid Grant

**Symptom:** Requests fail with "invalid_grant" or "Token has been expired or revoked"

**Solution:**
```bash
rm ~/.fgp/auth/google/gmail_token.pickle
fgp restart gmail
# Re-authorize when browser opens
```

### Daemon Not Starting

**Symptom:** `fgp start gmail` fails or daemon exits immediately

**Check:**
1. Socket permissions: `ls -la ~/.fgp/services/gmail/`
2. Python available: `which python3`
3. Logs: `cat ~/.fgp/logs/gmail.log`

### Rate Limiting (429 Error)

**Symptom:** Requests fail with "Quota exceeded" or 429 status

**Solutions:**
1. Gmail API has daily limits (~1B quota units/day for free)
2. Reduce request frequency
3. Use batch operations where possible
4. Check quota at [Google Cloud Console](https://console.cloud.google.com/apis/api/gmail.googleapis.com/quotas)

### Empty Results

**Symptom:** Queries return empty results when emails exist

**Check:**
1. Search syntax is correct (Gmail search operators)
2. Account has the expected emails
3. Try simpler query first: `fgp call gmail.inbox`

### Connection Refused

**Symptom:** "Connection refused" when calling daemon

**Solution:**
```bash
# Check if daemon is running
pgrep -f fgp-gmail

# Restart daemon
fgp stop gmail
fgp start gmail

# Check socket exists
ls ~/.fgp/services/gmail/daemon.sock
```

## License

MIT License - see [LICENSE](LICENSE) for details.
Expand Down
140 changes: 140 additions & 0 deletions examples/basic_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Gmail Daemon - Basic Operations Example

Demonstrates common Gmail operations using the FGP Gmail daemon.
Requires: Gmail daemon running (`fgp start gmail`)
"""

import json
import socket
import uuid
from pathlib import Path

SOCKET_PATH = Path.home() / ".fgp/services/gmail/daemon.sock"


def call_daemon(method: str, params: dict = None) -> dict:
"""Send a request to the Gmail daemon and return the response."""
request = {
"id": str(uuid.uuid4()),
"v": 1,
"method": method,
"params": params or {}
}

with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(str(SOCKET_PATH))
sock.sendall((json.dumps(request) + "\n").encode())

response = b""
while True:
chunk = sock.recv(4096)
if not chunk:
break
response += chunk
if b"\n" in response:
break

return json.loads(response.decode().strip())


def list_inbox(max_results: int = 10):
"""List recent emails from inbox."""
print(f"\n📬 Listing {max_results} recent emails...")

result = call_daemon("gmail.inbox", {"max_results": max_results})

if result.get("ok"):
emails = result["result"].get("emails", [])
for email in emails:
print(f" • {email.get('subject', '(no subject)')}")
print(f" From: {email.get('from', 'unknown')}")
print(f" Date: {email.get('date', 'unknown')}")
print()
else:
print(f" ❌ Error: {result.get('error')}")


def search_emails(query: str, max_results: int = 5):
"""Search for emails matching a query."""
print(f"\n🔍 Searching for: {query}")

result = call_daemon("gmail.search", {
"query": query,
"max_results": max_results
})

if result.get("ok"):
emails = result["result"].get("emails", [])
print(f" Found {len(emails)} matching emails")
for email in emails:
print(f" • {email.get('subject', '(no subject)')}")
else:
print(f" ❌ Error: {result.get('error')}")


def get_unread_count():
"""Get count of unread emails."""
print("\n📊 Checking unread emails...")

result = call_daemon("gmail.unread", {})

if result.get("ok"):
count = result["result"].get("count", 0)
print(f" You have {count} unread emails")
else:
print(f" ❌ Error: {result.get('error')}")


def read_thread(thread_id: str):
"""Read a specific email thread."""
print(f"\n📖 Reading thread: {thread_id}")

result = call_daemon("gmail.thread", {"thread_id": thread_id})

if result.get("ok"):
thread = result["result"]
messages = thread.get("messages", [])
print(f" Thread has {len(messages)} messages")
for msg in messages:
print(f" • {msg.get('snippet', '')[:100]}...")
else:
print(f" ❌ Error: {result.get('error')}")


def send_email(to: str, subject: str, body: str):
"""Send an email."""
print(f"\n✉️ Sending email to: {to}")

result = call_daemon("gmail.send", {
"to": to,
"subject": subject,
"body": body
})

if result.get("ok"):
print(f" ✅ Email sent! Message ID: {result['result'].get('message_id')}")
else:
print(f" ❌ Error: {result.get('error')}")


if __name__ == "__main__":
print("Gmail Daemon Examples")
print("=" * 40)

# Check daemon health first
health = call_daemon("health")
if not health.get("ok"):
print("❌ Gmail daemon not running. Start with: fgp start gmail")
exit(1)

print("✅ Gmail daemon is healthy")

# Run examples
get_unread_count()
list_inbox(max_results=5)
search_emails("is:unread", max_results=3)

# Uncomment to send a test email:
# send_email("test@example.com", "Test from FGP", "Hello from the Gmail daemon!")
61 changes: 55 additions & 6 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
"methods": [
{
"name": "inbox",
"name": "gmail.inbox",
"description": "List recent inbox emails",
"params": [
{
Expand All @@ -25,12 +25,12 @@
]
},
{
"name": "unread",
"name": "gmail.unread",
"description": "Get unread email count and summaries",
"params": []
},
{
"name": "search",
"name": "gmail.search",
"description": "Search emails by query",
"params": [
{
Expand All @@ -47,8 +47,19 @@
]
},
{
"name": "send",
"description": "Send an email",
"name": "gmail.read",
"description": "Read full email with body and attachment info",
"params": [
{
"name": "message_id",
"type": "string",
"required": true
}
]
},
{
"name": "gmail.send",
"description": "Send an email with optional attachments",
"params": [
{
"name": "to",
Expand All @@ -64,11 +75,49 @@
"name": "body",
"type": "string",
"required": true
},
{
"name": "cc",
"type": "string",
"required": false
},
{
"name": "bcc",
"type": "string",
"required": false
},
{
"name": "attachments",
"type": "array",
"required": false,
"description": "List of {filename, data (base64)} or {path}"
}
]
},
{
"name": "gmail.download_attachment",
"description": "Download an attachment from an email",
"params": [
{
"name": "message_id",
"type": "string",
"required": true
},
{
"name": "attachment_id",
"type": "string",
"required": true
},
{
"name": "save_path",
"type": "string",
"required": false,
"description": "Path to save file (returns base64 if not specified)"
}
]
},
{
"name": "thread",
"name": "gmail.thread",
"description": "Get email thread by ID",
"params": [
{
Expand Down
Loading
Loading