Files
Orchestrator/shared/OrgMyLife/app/services/gmail_sync.py
T
ankn a5f8fb49ab 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.
2026-06-30 20:39:52 +02:00

291 lines
8.8 KiB
Python

"""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