Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
"""Gmail IMAP sync — second email source for task ingestion.
|
||||
|
||||
Requirements:
|
||||
- Enable IMAP in Gmail settings (Settings → See all settings → Forwarding and POP/IMAP)
|
||||
- Generate an App Password at https://myaccount.google.com/apppasswords
|
||||
(requires 2FA to be enabled on your Google account)
|
||||
- Set GMAIL_USERNAME and GMAIL_PASSWORD env vars
|
||||
"""
|
||||
|
||||
import os
|
||||
import imaplib
|
||||
import email as email_lib
|
||||
from email.header import decode_header
|
||||
import traceback
|
||||
from app.db.session import SessionLocal
|
||||
from app.models import Task
|
||||
|
||||
|
||||
def _get_gmail_connection():
|
||||
server = os.getenv("GMAIL_IMAP_SERVER", "imap.gmail.com")
|
||||
port = int(os.getenv("GMAIL_IMAP_PORT", "993"))
|
||||
username = os.getenv("GMAIL_USERNAME", "")
|
||||
password = os.getenv("GMAIL_PASSWORD", "")
|
||||
|
||||
if not username or not password:
|
||||
return None # Not configured, skip silently
|
||||
|
||||
conn = imaplib.IMAP4_SSL(server, port)
|
||||
conn.login(username, password)
|
||||
return conn
|
||||
|
||||
|
||||
def _decode_header_value(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
parts = decode_header(value)
|
||||
decoded = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
decoded.append(part)
|
||||
return " ".join(decoded)
|
||||
|
||||
|
||||
def _get_body_snippet(msg, max_chars: int = 300) -> str:
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain" and part.get("Content-Disposition") is None:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(part.get_content_charset() or "utf-8", errors="replace")[:max_chars]
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(msg.get_content_charset() or "utf-8", errors="replace")[:max_chars]
|
||||
return ""
|
||||
|
||||
|
||||
def sync_gmail():
|
||||
"""Fetch unread emails from Gmail inbox and create tasks.
|
||||
|
||||
Returns True on success (or if not configured), False on error.
|
||||
"""
|
||||
try:
|
||||
conn = _get_gmail_connection()
|
||||
except imaplib.IMAP4.error as e:
|
||||
print(f"Gmail IMAP auth failed: {e}")
|
||||
print("Make sure you're using an App Password (not your regular Google password).")
|
||||
print("Generate one at: https://myaccount.google.com/apppasswords")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Gmail IMAP connection failed: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if conn is None:
|
||||
# Not configured — skip silently
|
||||
return True
|
||||
|
||||
try:
|
||||
conn.select("INBOX")
|
||||
|
||||
# Search for unread emails
|
||||
status, data = conn.uid("SEARCH", None, "UNSEEN")
|
||||
if status != "OK" or not data[0]:
|
||||
conn.logout()
|
||||
return True # Nothing to process
|
||||
|
||||
uid_list = data[0].split()
|
||||
db = SessionLocal()
|
||||
|
||||
synced_count = 0
|
||||
for uid_bytes in uid_list:
|
||||
try:
|
||||
_, msg_data = conn.uid("FETCH", uid_bytes, "(RFC822)")
|
||||
if not msg_data or not msg_data[0]:
|
||||
continue
|
||||
raw = msg_data[0][1]
|
||||
msg = email_lib.message_from_bytes(raw)
|
||||
|
||||
message_id = msg.get("Message-ID", "").strip()
|
||||
if not message_id:
|
||||
continue
|
||||
|
||||
title = _decode_header_value(msg.get("Subject", "(No Subject)"))
|
||||
sender = _decode_header_value(msg.get("From", ""))
|
||||
body_snippet = _get_body_snippet(msg)
|
||||
|
||||
# Parse email send date
|
||||
from email.utils import parsedate_to_datetime
|
||||
email_date = None
|
||||
try:
|
||||
date_str = msg.get("Date")
|
||||
if date_str:
|
||||
email_date = parsedate_to_datetime(date_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Skip if already tracked (including dismissed)
|
||||
existing = db.query(Task).filter(Task.source_id == message_id).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Limit title length
|
||||
title = title[:200] if title else "(No Subject)"
|
||||
description = f"From: {sender}\n\n{body_snippet}" if sender else body_snippet
|
||||
|
||||
max_pos = db.query(Task).filter(Task.status == "open").count()
|
||||
db.add(Task(
|
||||
title=title,
|
||||
description=description[:2000], # Limit description length
|
||||
priority=4,
|
||||
position=max_pos,
|
||||
status="open",
|
||||
origin="gmail",
|
||||
source_id=message_id,
|
||||
source_url="https://mail.google.com/mail/",
|
||||
created_at=email_date,
|
||||
))
|
||||
synced_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing Gmail UID {uid_bytes}: {e}")
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
print(f"Gmail sync: {synced_count} new tasks from {len(uid_list)} unread emails")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Gmail sync error: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def gmail_archive_email(source_id: str) -> bool:
|
||||
"""Archive a Gmail email (remove from Inbox, keep in All Mail)."""
|
||||
try:
|
||||
conn = _get_gmail_connection()
|
||||
except Exception as e:
|
||||
print(f"Gmail connection failed: {e}")
|
||||
return False
|
||||
if conn is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
conn.select("INBOX")
|
||||
|
||||
# Find the email
|
||||
uid_bytes = _find_gmail_email(conn, source_id)
|
||||
if not uid_bytes:
|
||||
print(f" Gmail: email not found: {source_id[:40]}")
|
||||
return False
|
||||
|
||||
# Gmail archive = move to All Mail (remove INBOX label)
|
||||
# In IMAP terms: COPY to [Gmail]/All Mail, then delete from INBOX
|
||||
try:
|
||||
conn.uid("COPY", uid_bytes, "[Gmail]/All Mail")
|
||||
except Exception:
|
||||
pass # Might already be in All Mail
|
||||
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
|
||||
conn.expunge()
|
||||
print(f" Gmail: archived email")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Gmail archive error: {e}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def gmail_delete_email(source_id: str) -> bool:
|
||||
"""Delete a Gmail email (move to Trash)."""
|
||||
try:
|
||||
conn = _get_gmail_connection()
|
||||
except Exception as e:
|
||||
print(f"Gmail connection failed: {e}")
|
||||
return False
|
||||
if conn is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
conn.select("INBOX")
|
||||
|
||||
uid_bytes = _find_gmail_email(conn, source_id)
|
||||
if not uid_bytes:
|
||||
print(f" Gmail: email not found for delete: {source_id[:40]}")
|
||||
return False
|
||||
|
||||
# Move to Gmail Trash
|
||||
conn.uid("COPY", uid_bytes, "[Gmail]/Trash")
|
||||
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
|
||||
conn.expunge()
|
||||
print(f" Gmail: deleted email")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Gmail delete error: {e}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _find_gmail_email(conn, source_id: str):
|
||||
"""Find a Gmail email by Message-ID. Returns UID bytes or None."""
|
||||
# Try HEADER search
|
||||
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID "{source_id}"')
|
||||
if status == "OK" and data[0]:
|
||||
return data[0].split()[0]
|
||||
# Try without quotes
|
||||
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
|
||||
if status == "OK" and data[0]:
|
||||
return data[0].split()[0]
|
||||
return None
|
||||
|
||||
|
||||
def gmail_cleanup_dismissed(db_session=None):
|
||||
"""Check if Gmail tasks are still in INBOX, dismiss if not."""
|
||||
try:
|
||||
conn = _get_gmail_connection()
|
||||
except Exception:
|
||||
return
|
||||
if conn is None:
|
||||
return
|
||||
|
||||
try:
|
||||
conn.select("INBOX")
|
||||
db = db_session or SessionLocal()
|
||||
|
||||
open_gmail_tasks = db.query(Task).filter(
|
||||
Task.origin == "gmail",
|
||||
Task.status == "open",
|
||||
Task.source_id != None
|
||||
).all()
|
||||
|
||||
dismissed = 0
|
||||
for task in open_gmail_tasks:
|
||||
uid = _find_gmail_email(conn, task.source_id)
|
||||
if not uid:
|
||||
task.status = "dismissed"
|
||||
dismissed += 1
|
||||
|
||||
if dismissed:
|
||||
db.commit()
|
||||
print(f" Gmail: auto-dismissed {dismissed} tasks")
|
||||
|
||||
if not db_session:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Gmail cleanup error: {e}")
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user