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,339 @@
|
||||
import os
|
||||
import imaplib
|
||||
import email as email_lib
|
||||
import traceback
|
||||
from email.header import decode_header
|
||||
from app.db.session import SessionLocal
|
||||
from app.models import Task
|
||||
|
||||
# IMAP label constants
|
||||
LABEL_TODO = "todo"
|
||||
LABEL_NO_TODO = "no_todo"
|
||||
LABEL_DONE = "done"
|
||||
|
||||
|
||||
def _get_imap_connection():
|
||||
server = os.getenv("IMAP_SERVER")
|
||||
port = int(os.getenv("IMAP_PORT", "993"))
|
||||
username = os.getenv("EMAIL_USERNAME")
|
||||
password = os.getenv("EMAIL_PASSWORD")
|
||||
|
||||
if not all([server, username, password]):
|
||||
raise ValueError("IMAP credentials missing. Set IMAP_SERVER, IMAP_PORT, EMAIL_USERNAME, EMAIL_PASSWORD.")
|
||||
|
||||
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 _apply_label(conn, uid_bytes: bytes, label: str):
|
||||
try:
|
||||
conn.uid("STORE", uid_bytes, "+FLAGS", f"({label})")
|
||||
except Exception as e:
|
||||
print(f"Warning: could not apply label '{label}': {e}")
|
||||
|
||||
|
||||
def _remove_label(conn, uid_bytes: bytes, label: str):
|
||||
try:
|
||||
conn.uid("STORE", uid_bytes, "-FLAGS", f"({label})")
|
||||
except Exception as e:
|
||||
print(f"Warning: could not remove label '{label}': {e}")
|
||||
|
||||
|
||||
def _archive_email(conn, uid_bytes: bytes):
|
||||
"""Move email to archive folder. Uses IONOS year-based Archiv folders."""
|
||||
from datetime import datetime
|
||||
current_year = str(datetime.now().year)
|
||||
archive_folders = [
|
||||
f"Archiv/{current_year}", # IONOS: "Archiv/2026"
|
||||
"Archiv",
|
||||
"Archive",
|
||||
"INBOX.Archive",
|
||||
]
|
||||
for folder in archive_folders:
|
||||
try:
|
||||
result = conn.uid("COPY", uid_bytes, folder)
|
||||
if result[0] == "OK":
|
||||
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
|
||||
conn.expunge()
|
||||
print(f"Email archived to: {folder}")
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
print(f"Could not archive email. Tried: {archive_folders}")
|
||||
return False
|
||||
|
||||
|
||||
def _delete_email(conn, uid_bytes: bytes):
|
||||
"""Permanently delete email (move to Papierkorb/Trash)."""
|
||||
trash_folders = [
|
||||
"Papierkorb", # IONOS German
|
||||
"Trash",
|
||||
"INBOX.Trash",
|
||||
"Deleted Items",
|
||||
]
|
||||
for folder in trash_folders:
|
||||
try:
|
||||
result = conn.uid("COPY", uid_bytes, folder)
|
||||
if result[0] == "OK":
|
||||
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
|
||||
conn.expunge()
|
||||
print(f"Email moved to trash: {folder}")
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
# Fallback: just mark as deleted in INBOX
|
||||
try:
|
||||
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
|
||||
conn.expunge()
|
||||
print("Email deleted from INBOX (no trash folder found)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Could not delete email: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def sync_emails():
|
||||
"""Fetch untracked INBOX emails and create tasks."""
|
||||
try:
|
||||
conn = _get_imap_connection()
|
||||
except ValueError as e:
|
||||
print(f"IMAP not configured: {e}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"IMAP connection failed: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
try:
|
||||
conn.select("INBOX")
|
||||
|
||||
# Try keyword-based search, fall back to UNSEEN
|
||||
try:
|
||||
status, data = conn.uid(
|
||||
"SEARCH", None,
|
||||
f"UNKEYWORD {LABEL_TODO} UNKEYWORD {LABEL_NO_TODO} UNKEYWORD {LABEL_DONE}"
|
||||
)
|
||||
except Exception:
|
||||
print("UNKEYWORD not supported, falling back to UNSEEN")
|
||||
status, data = conn.uid("SEARCH", None, "UNSEEN")
|
||||
|
||||
if status != "OK" or not data[0]:
|
||||
return True # Nothing to process
|
||||
|
||||
uid_list = data[0].split()
|
||||
db = SessionLocal()
|
||||
|
||||
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)"))
|
||||
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
|
||||
|
||||
existing = db.query(Task).filter(Task.source_id == message_id).first()
|
||||
if existing:
|
||||
_apply_label(conn, uid_bytes, LABEL_TODO)
|
||||
continue
|
||||
|
||||
max_pos = db.query(Task).filter(Task.status == "open").count()
|
||||
db.add(Task(
|
||||
title=title[:200],
|
||||
description=body_snippet[:2000],
|
||||
priority=4,
|
||||
position=max_pos,
|
||||
status="open",
|
||||
origin="dhive_email",
|
||||
source_id=message_id,
|
||||
source_url=os.getenv("EMAIL_WEBMAIL_URL", ""),
|
||||
created_at=email_date,
|
||||
))
|
||||
_apply_label(conn, uid_bytes, LABEL_TODO)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing email UID {uid_bytes}: {e}")
|
||||
|
||||
# Cleanup: dismiss tasks whose emails are no longer in INBOX
|
||||
open_email_tasks = db.query(Task).filter(
|
||||
Task.origin == "dhive_email",
|
||||
Task.status == "open",
|
||||
Task.source_id != None
|
||||
).all()
|
||||
|
||||
dismissed_count = 0
|
||||
for task in open_email_tasks:
|
||||
try:
|
||||
# Search by Message-ID header
|
||||
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{task.source_id}"')
|
||||
if status == "OK" and data[0]:
|
||||
# Email still in INBOX — keep task open
|
||||
continue
|
||||
# Also try without quotes (some servers are picky)
|
||||
status2, data2 = conn.uid("SEARCH", None, f'HEADER Message-ID {task.source_id}')
|
||||
if status2 == "OK" and data2[0]:
|
||||
continue
|
||||
# Email not found in INBOX — dismiss
|
||||
task.status = "dismissed"
|
||||
dismissed_count += 1
|
||||
except Exception as e:
|
||||
print(f" Cleanup check failed for {task.source_id[:30]}: {e}")
|
||||
|
||||
if dismissed_count:
|
||||
print(f" Auto-dismissed {dismissed_count} tasks (emails no longer in INBOX)")
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Email sync error: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def mark_email_done_and_archive(source_id: str) -> bool:
|
||||
"""Find an email by Message-ID, mark done, and archive it."""
|
||||
try:
|
||||
conn = _get_imap_connection()
|
||||
except Exception as e:
|
||||
print(f"IMAP connection failed: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
conn.select("INBOX")
|
||||
|
||||
# Try multiple search approaches
|
||||
uid_bytes = None
|
||||
|
||||
# Approach 1: HEADER search with quotes
|
||||
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{source_id}"')
|
||||
if status == "OK" and data[0]:
|
||||
uid_bytes = data[0].split()[0]
|
||||
|
||||
# Approach 2: Without CHARSET
|
||||
if not uid_bytes:
|
||||
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
|
||||
if status == "OK" and data[0]:
|
||||
uid_bytes = data[0].split()[0]
|
||||
|
||||
# Approach 3: Search ALL and match manually (slow but reliable)
|
||||
if not uid_bytes:
|
||||
print(f" Header search failed, trying full scan for: {source_id[:40]}...")
|
||||
status, data = conn.uid("SEARCH", None, "ALL")
|
||||
if status == "OK" and data[0]:
|
||||
for uid in data[0].split()[-100:]: # Check last 100 emails
|
||||
try:
|
||||
_, msg_data = conn.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (Message-ID)])")
|
||||
if msg_data and msg_data[0] and source_id.encode() in msg_data[0][1]:
|
||||
uid_bytes = uid
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not uid_bytes:
|
||||
print(f" Email not found in INBOX: {source_id[:50]}")
|
||||
return False
|
||||
|
||||
print(f" Found email UID: {uid_bytes}")
|
||||
_remove_label(conn, uid_bytes, LABEL_TODO)
|
||||
_apply_label(conn, uid_bytes, LABEL_DONE)
|
||||
result = _archive_email(conn, uid_bytes)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error archiving email {source_id[:50]}: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def delete_email_from_server(source_id: str) -> bool:
|
||||
"""Find an email by Message-ID and permanently delete it (move to Trash)."""
|
||||
try:
|
||||
conn = _get_imap_connection()
|
||||
except Exception as e:
|
||||
print(f"IMAP connection failed: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
conn.select("INBOX")
|
||||
|
||||
# Try multiple search approaches
|
||||
uid_bytes = None
|
||||
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{source_id}"')
|
||||
if status == "OK" and data[0]:
|
||||
uid_bytes = data[0].split()[0]
|
||||
if not uid_bytes:
|
||||
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
|
||||
if status == "OK" and data[0]:
|
||||
uid_bytes = data[0].split()[0]
|
||||
|
||||
if not uid_bytes:
|
||||
print(f" Email not found for delete: {source_id[:50]}")
|
||||
return False
|
||||
|
||||
_delete_email(conn, uid_bytes)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error deleting email {source_id[:50]}: {e}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user