Skip to content

Commit 1c4cff8

Browse files
committed
Add email-first knowledge graph with IMAP ingestion
- Add IMAP polling service for email ingestion - Add MIME parser for extracting email content and thread refs - Add email lens with primitives (person, org, action_item, decision, date, topic) - Add auto-extraction pipeline with webhook subscription - Extend extraction.py with zone-aware email extraction (subject/body) - Add inbox web UI with email list, search, pagination - Add email detail view with inline anchor highlights - Add D3 graph panel showing anchor connections - Add sync state persistence for resumable polling - Add Flask routes for /inbox and email APIs
1 parent 339b378 commit 1c4cff8

17 files changed

Lines changed: 3905 additions & 0 deletions
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Create the email auto-extraction subscription in Memex.
4+
5+
This subscription triggers a webhook when Email nodes are created,
6+
allowing automatic anchor extraction.
7+
"""
8+
9+
import sys
10+
import os
11+
import requests
12+
13+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'workspace'))
14+
15+
from config.email import email_config
16+
17+
MEMEX_URL = os.getenv("MEMEX_URL", "http://localhost:8080")
18+
19+
SUBSCRIPTION = {
20+
"id": "sub:email-auto-extract",
21+
"name": "Email Auto-Extraction",
22+
"description": "Automatically extract anchors when emails are ingested",
23+
"pattern": {
24+
"event_types": ["node.created"],
25+
"node_types": ["Email"]
26+
},
27+
"webhook": {
28+
"url": email_config.extraction_webhook,
29+
"method": "POST",
30+
"headers": {
31+
"Content-Type": "application/json"
32+
},
33+
"retry": {
34+
"max_attempts": 3,
35+
"backoff_ms": 1000
36+
}
37+
},
38+
"enabled": True
39+
}
40+
41+
42+
def create_subscription():
43+
"""Create the email extraction subscription"""
44+
print("Creating email auto-extraction subscription...")
45+
print(f" Memex URL: {MEMEX_URL}")
46+
print(f" Webhook URL: {SUBSCRIPTION['webhook']['url']}")
47+
48+
try:
49+
# Check if subscription exists
50+
resp = requests.get(f"{MEMEX_URL}/api/subscriptions/{SUBSCRIPTION['id']}")
51+
if resp.status_code == 200:
52+
print(f" Subscription {SUBSCRIPTION['id']} already exists, updating...")
53+
resp = requests.put(
54+
f"{MEMEX_URL}/api/subscriptions/{SUBSCRIPTION['id']}",
55+
json=SUBSCRIPTION
56+
)
57+
else:
58+
print(f" Creating new subscription...")
59+
resp = requests.post(
60+
f"{MEMEX_URL}/api/subscriptions",
61+
json=SUBSCRIPTION
62+
)
63+
64+
if resp.status_code in [200, 201]:
65+
print(f" Successfully created/updated subscription: {SUBSCRIPTION['id']}")
66+
print("\nSubscription details:")
67+
print(f" - Triggers on: {SUBSCRIPTION['pattern']['event_types']}")
68+
print(f" - For node types: {SUBSCRIPTION['pattern']['node_types']}")
69+
print(f" - Webhook: {SUBSCRIPTION['webhook']['url']}")
70+
return True
71+
else:
72+
print(f" Failed to create subscription: {resp.status_code}")
73+
print(f" Response: {resp.text}")
74+
return False
75+
76+
except requests.exceptions.ConnectionError:
77+
print(f" Error: Could not connect to Memex at {MEMEX_URL}")
78+
print(" Is memex-server running?")
79+
return False
80+
except Exception as e:
81+
print(f" Error: {e}")
82+
return False
83+
84+
85+
def list_subscriptions():
86+
"""List all subscriptions"""
87+
try:
88+
resp = requests.get(f"{MEMEX_URL}/api/subscriptions")
89+
if resp.status_code == 200:
90+
data = resp.json()
91+
subs = data.get("subscriptions", [])
92+
print(f"\nExisting subscriptions ({len(subs)}):")
93+
for sub in subs:
94+
status = "enabled" if sub.get("enabled") else "disabled"
95+
print(f" - {sub.get('id')}: {sub.get('name')} [{status}]")
96+
else:
97+
print("Could not list subscriptions")
98+
except Exception as e:
99+
print(f"Error listing subscriptions: {e}")
100+
101+
102+
def main():
103+
import argparse
104+
105+
parser = argparse.ArgumentParser(description="Manage email extraction subscription")
106+
parser.add_argument("--list", action="store_true", help="List all subscriptions")
107+
parser.add_argument("--delete", action="store_true", help="Delete the subscription")
108+
109+
args = parser.parse_args()
110+
111+
if args.list:
112+
list_subscriptions()
113+
elif args.delete:
114+
try:
115+
resp = requests.delete(f"{MEMEX_URL}/api/subscriptions/{SUBSCRIPTION['id']}")
116+
if resp.status_code in [200, 204]:
117+
print(f"Deleted subscription: {SUBSCRIPTION['id']}")
118+
else:
119+
print(f"Failed to delete: {resp.status_code}")
120+
except Exception as e:
121+
print(f"Error: {e}")
122+
else:
123+
success = create_subscription()
124+
if success:
125+
list_subscriptions()
126+
sys.exit(0 if success else 1)
127+
128+
129+
if __name__ == "__main__":
130+
main()

scripts/run_email_poller.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Run the email IMAP poller.
4+
5+
This script starts the email polling service that:
6+
1. Connects to the configured IMAP server
7+
2. Polls for new emails at regular intervals
8+
3. Ingests emails into Memex as Email nodes
9+
4. Persists sync state for resumable operation
10+
11+
Configuration via environment variables:
12+
- IMAP_HOST: IMAP server hostname (default: imap.gmail.com)
13+
- IMAP_PORT: IMAP server port (default: 993)
14+
- EMAIL_USERNAME: Email address/username
15+
- EMAIL_PASSWORD: Email password (use app-specific password for Gmail)
16+
- EMAIL_MAILBOX: Mailbox to poll (default: INBOX)
17+
- EMAIL_POLL_INTERVAL: Seconds between polls (default: 60)
18+
- EMAIL_BATCH_SIZE: Max emails per poll (default: 50)
19+
"""
20+
21+
import sys
22+
import os
23+
import signal
24+
import threading
25+
26+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'workspace'))
27+
28+
from config.email import email_config
29+
from services.email_ingestion import IMAPPoller
30+
from services.email_sync import get_sync_state
31+
32+
33+
def main():
34+
import argparse
35+
36+
parser = argparse.ArgumentParser(
37+
description="Run the email IMAP poller",
38+
formatter_class=argparse.RawDescriptionHelpFormatter,
39+
epilog="""
40+
Examples:
41+
# Run with environment variables
42+
EMAIL_USERNAME=user@gmail.com EMAIL_PASSWORD=xxxx python run_email_poller.py
43+
44+
# Run once (no polling loop)
45+
python run_email_poller.py --once
46+
47+
# Reset sync state and start fresh
48+
python run_email_poller.py --reset
49+
50+
# Show current sync status
51+
python run_email_poller.py --status
52+
"""
53+
)
54+
parser.add_argument("--once", action="store_true", help="Poll once and exit")
55+
parser.add_argument("--reset", action="store_true", help="Reset sync state")
56+
parser.add_argument("--status", action="store_true", help="Show sync status")
57+
parser.add_argument("--test", action="store_true", help="Test connection only")
58+
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
59+
60+
args = parser.parse_args()
61+
62+
# Validate configuration
63+
errors = email_config.validate()
64+
if errors and not args.status:
65+
print("Configuration errors:")
66+
for error in errors:
67+
print(f" - {error}")
68+
print("\nSet environment variables or create a .env file")
69+
sys.exit(1)
70+
71+
sync_state = get_sync_state()
72+
73+
# Handle --status
74+
if args.status:
75+
stats = sync_state.get_stats()
76+
print("Email Sync Status")
77+
print("=" * 40)
78+
print(f"Account: {email_config.username or '(not configured)'}")
79+
print(f"IMAP Host: {email_config.imap_host}")
80+
print(f"Mailbox: {email_config.mailbox}")
81+
print(f"Last UID: {stats['last_uid']}")
82+
print(f"Last Sync: {stats['last_sync'] or 'never'}")
83+
print(f"Total Emails Ingested: {stats['total_emails_ingested']}")
84+
print(f"Total Errors: {stats['total_errors']}")
85+
return
86+
87+
# Handle --reset
88+
if args.reset:
89+
print("Resetting sync state...")
90+
sync_state.reset()
91+
print("Sync state reset. Next poll will start fresh.")
92+
return
93+
94+
# Create poller
95+
poller = IMAPPoller(email_config)
96+
97+
# Set callbacks
98+
def on_email_ingested(node_id, email_msg):
99+
if args.verbose:
100+
print(f" Ingested: {email_msg.subject[:60]}")
101+
102+
def on_error(error):
103+
print(f" Error: {error}")
104+
sync_state.record_sync(0, errors=1)
105+
106+
poller.on_email_ingested = on_email_ingested
107+
poller.on_error = on_error
108+
109+
# Restore last UID from sync state
110+
poller.last_uid = sync_state.get_last_uid()
111+
112+
print("=" * 50)
113+
print("MEMEX EMAIL POLLER")
114+
print("=" * 50)
115+
print(f"Host: {email_config.imap_host}")
116+
print(f"User: {email_config.username}")
117+
print(f"Mailbox: {email_config.mailbox}")
118+
print(f"Poll Interval: {email_config.poll_interval}s")
119+
print(f"Starting from UID: {poller.last_uid}")
120+
print("=" * 50)
121+
122+
# Handle --test
123+
if args.test:
124+
print("\nTesting IMAP connection...")
125+
if poller.connect():
126+
print("Connection successful!")
127+
poller.disconnect()
128+
else:
129+
print("Connection failed!")
130+
sys.exit(1)
131+
return
132+
133+
# Handle --once
134+
if args.once:
135+
print("\nPolling once...")
136+
count = poller.poll_once()
137+
print(f"Ingested {count} emails")
138+
139+
# Save state
140+
sync_state.set_last_uid(poller.last_uid)
141+
sync_state.record_sync(count)
142+
143+
poller.disconnect()
144+
return
145+
146+
# Setup signal handlers for graceful shutdown
147+
stop_event = threading.Event()
148+
149+
def signal_handler(signum, frame):
150+
print("\nShutting down...")
151+
stop_event.set()
152+
poller.stop()
153+
154+
signal.signal(signal.SIGINT, signal_handler)
155+
signal.signal(signal.SIGTERM, signal_handler)
156+
157+
# Run polling loop
158+
print("\nStarting poll loop (Ctrl+C to stop)...")
159+
print()
160+
161+
try:
162+
while not stop_event.is_set():
163+
count = poller.poll_once()
164+
165+
# Save state after each poll
166+
if count > 0 or poller.last_uid > sync_state.get_last_uid():
167+
sync_state.set_last_uid(poller.last_uid)
168+
sync_state.record_sync(count)
169+
170+
# Wait for next poll
171+
stop_event.wait(timeout=email_config.poll_interval)
172+
173+
except KeyboardInterrupt:
174+
pass
175+
finally:
176+
poller.disconnect()
177+
print("Poller stopped.")
178+
stats = sync_state.get_stats()
179+
print(f"Final UID: {stats['last_uid']}")
180+
print(f"Total emails ingested this session: {stats['total_emails_ingested']}")
181+
182+
183+
if __name__ == "__main__":
184+
main()

0 commit comments

Comments
 (0)